From 442263a6a79d16cd7096e92047e61375e342dcbf Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:10:00 +0200 Subject: [PATCH] feat: add support acp in common chat (#369) --- CMakeLists.txt | 7 + QodeAssist.qrc | 1 + README.md | 58 +- docs/acp-agents.md | 114 + docs/agent-roles.md | 174 - docs/chat-summarization.md | 28 +- docs/file-context.md | 22 +- docs/project-rules.md | 35 - docs/quick-refactoring.md | 2 - resources/agents/acp-registry-snapshot.json | 1351 ++++++ sources/CMakeLists.txt | 1 + sources/ChatView/AgentRoleController.cpp | 124 - sources/ChatView/AgentRoleController.hpp | 39 - ...tFileManager.cpp => AttachmentStaging.cpp} | 27 +- ...tFileManager.hpp => AttachmentStaging.hpp} | 7 +- sources/ChatView/CMakeLists.txt | 17 +- sources/ChatView/ChatCompressor.cpp | 63 +- sources/ChatView/ChatCompressor.hpp | 14 + .../ChatView/ChatConfigurationController.cpp | 86 +- .../ChatView/ChatConfigurationController.hpp | 21 + sources/ChatView/ChatController.cpp | 231 +- sources/ChatView/ChatController.hpp | 53 +- sources/ChatView/ChatFileStore.cpp | 429 ++ sources/ChatView/ChatFileStore.hpp | 89 + sources/ChatView/ChatHistoryBridge.cpp | 107 - sources/ChatView/ChatHistoryBridge.hpp | 37 - sources/ChatView/ChatHistoryStore.cpp | 236 - sources/ChatView/ChatHistoryStore.hpp | 51 - sources/ChatView/ChatModel.cpp | 106 +- sources/ChatView/ChatModel.hpp | 49 +- sources/ChatView/ChatRootView.cpp | 739 ++-- sources/ChatView/ChatRootView.hpp | 149 +- sources/ChatView/ChatSerializer.cpp | 169 - sources/ChatView/ChatSerializer.hpp | 40 - sources/ChatView/ConversationCoordinator.cpp | 373 ++ sources/ChatView/ConversationCoordinator.hpp | 100 + sources/ChatView/ConversationPorts.hpp | 70 + sources/ChatView/FileEditController.cpp | 44 +- sources/ChatView/FileMentionItem.cpp | 14 +- sources/ChatView/FileMentionItem.hpp | 6 +- sources/ChatView/InputTokenCounter.cpp | 15 +- sources/ChatView/InputTokenCounter.hpp | 2 - sources/ChatView/LlmChatBackend.cpp | 198 +- sources/ChatView/LlmChatBackend.hpp | 25 +- sources/ChatView/TurnContextAdapters.cpp | 38 +- sources/ChatView/TurnContextAdapters.hpp | 18 +- sources/ChatView/icons/link-file-dark.svg | 12 - sources/ChatView/icons/link-file-light.svg | 12 - sources/ChatView/qml/RootItem.qml | 237 +- .../ChatView/qml/chatparts/BlockPayload.js | 29 + .../ChatView/qml/chatparts/FileEditBlock.qml | 29 +- .../qml/chatparts/PermissionBlock.qml | 208 + sources/ChatView/qml/chatparts/PlanBlock.qml | 151 + sources/ChatView/qml/chatparts/ToolBlock.qml | 172 +- sources/ChatView/qml/controls/BottomBar.qml | 39 +- .../ChatView/qml/controls/ContextViewer.qml | 298 +- sources/ChatView/qml/controls/DropZone.qml | 148 + .../qml/controls/SkillCommandPopup.qml | 53 +- .../ChatView/qml/controls/SplitDropZone.qml | 276 -- sources/ChatView/qml/controls/TopBar.qml | 76 +- sources/acp/AcpChatBackend.cpp | 1067 +++++ sources/acp/AcpChatBackend.hpp | 135 + sources/acp/AgentBinding.cpp | 88 + sources/acp/AgentBinding.hpp | 30 + sources/acp/AgentCatalog.cpp | 65 + sources/acp/AgentCatalog.hpp | 35 + sources/acp/AgentCatalogStore.cpp | 165 + sources/acp/AgentCatalogStore.hpp | 57 + sources/acp/AgentDefinition.cpp | 94 + sources/acp/AgentDefinition.hpp | 69 + sources/acp/AgentKnowledgeService.hpp | 21 + sources/acp/AgentLaunch.cpp | 185 + sources/acp/AgentLaunch.hpp | 29 + sources/acp/AgentRegistryParser.cpp | 223 + sources/acp/AgentRegistryParser.hpp | 30 + sources/acp/AgentSpawn.cpp | 76 + sources/acp/AgentSpawn.hpp | 30 + sources/acp/AgentTester.cpp | 180 + sources/acp/AgentTester.hpp | 50 + sources/acp/CMakeLists.txt | 29 + sources/acp/ChatPermissionProvider.cpp | 56 + sources/acp/ChatPermissionProvider.hpp | 41 + sources/completion/LLMClientInterface.cpp | 12 - sources/completion/QodeAssistClient.cpp | 5 - sources/context/CMakeLists.txt | 3 +- sources/context/ContentFile.hpp | 1 + sources/context/ContextManager.cpp | 16 +- sources/context/ContextManager.hpp | 1 + sources/context/DocumentContextReader.cpp | 6 - ...ChangesManager.cpp => FileEditManager.cpp} | 134 +- .../{ChangesManager.h => FileEditManager.hpp} | 53 +- sources/context/IContextManager.hpp | 1 + sources/context/RulesLoader.cpp | 166 - sources/context/RulesLoader.hpp | 42 - sources/external/llmqore | 2 +- sources/mcp/AgentKnowledgeServer.cpp | 129 + sources/mcp/AgentKnowledgeServer.hpp | 48 + sources/plugin/qodeassist.cpp | 16 +- sources/refactor/QuickRefactorHandler.cpp | 12 - sources/session/AgentPlan.cpp | 57 + sources/session/AgentPlan.hpp | 49 + sources/session/BlockCodec.cpp | 38 + sources/session/BlockCodec.hpp | 32 + sources/session/CMakeLists.txt | 5 + sources/session/ChatBackend.hpp | 12 + sources/session/ContentBlock.hpp | 22 +- sources/session/FencedText.cpp | 36 + sources/session/FencedText.hpp | 13 + sources/session/FileEditPayload.cpp | 31 +- sources/session/HistoryProjection.cpp | 141 +- sources/session/HistoryProjection.hpp | 27 +- sources/session/HistorySerializer.cpp | 31 +- sources/session/PermissionRequest.cpp | 116 + sources/session/PermissionRequest.hpp | 87 + sources/session/Session.cpp | 455 +- sources/session/Session.hpp | 27 +- sources/session/SessionEvent.cpp | 9 +- sources/session/SessionEvent.hpp | 55 +- sources/session/TurnContext.cpp | 12 - sources/session/TurnContext.hpp | 11 +- sources/session/TurnContextBuilder.cpp | 15 +- sources/session/TurnContextBuilder.hpp | 17 +- sources/session/TurnContextPorts.hpp | 12 - sources/session/TurnLedger.cpp | 103 + sources/session/TurnLedger.hpp | 50 + sources/session/TurnRequest.hpp | 9 - sources/settings/AgentRole.cpp | 211 - sources/settings/AgentRole.hpp | 67 - sources/settings/AgentRoleDialog.cpp | 110 - sources/settings/AgentRoleDialog.hpp | 50 - sources/settings/AgentRolesWidget.cpp | 242 - sources/settings/AgentRolesWidget.hpp | 43 - sources/settings/AgentsSettings.cpp | 56 + sources/settings/AgentsSettings.hpp | 22 + sources/settings/AgentsWidget.cpp | 348 ++ sources/settings/AgentsWidget.hpp | 74 + sources/settings/CMakeLists.txt | 3 - sources/settings/ChatAssistantSettings.cpp | 24 - sources/settings/ChatAssistantSettings.hpp | 4 - sources/settings/CodeCompletionSettings.cpp | 13 +- sources/settings/CodeCompletionSettings.hpp | 2 - sources/settings/SettingsConstants.hpp | 9 +- sources/tools/EditFileTool.cpp | 18 +- sources/tools/EditorStateTools.cpp | 266 ++ sources/tools/EditorStateTools.hpp | 66 + sources/tools/FindFileTool.hpp | 1 + sources/tools/GetIssuesListTool.hpp | 1 + sources/tools/ListProjectFilesTool.hpp | 1 + sources/tools/ProjectSearchTool.hpp | 1 + sources/tools/ReadFileTool.hpp | 1 + sources/tools/ReadOriginalHistoryTool.cpp | 6 + sources/tools/ReadOriginalHistoryTool.hpp | 1 + sources/tools/SkillTool.hpp | 1 + sources/tools/TodoTool.hpp | 1 + tests/FakeAcpAgent.hpp | 324 ++ tests/FakeLlmProvider.hpp | 184 + tests/QodeAssistTest.cpp | 3910 ++++++++++++++++- tests/QodeAssistTest.hpp | 135 +- 158 files changed, 14270 insertions(+), 4118 deletions(-) create mode 100644 docs/acp-agents.md delete mode 100644 docs/agent-roles.md delete mode 100644 docs/project-rules.md create mode 100644 resources/agents/acp-registry-snapshot.json delete mode 100644 sources/ChatView/AgentRoleController.cpp delete mode 100644 sources/ChatView/AgentRoleController.hpp rename sources/ChatView/{ChatFileManager.cpp => AttachmentStaging.cpp} (86%) rename sources/ChatView/{ChatFileManager.hpp => AttachmentStaging.hpp} (88%) create mode 100644 sources/ChatView/ChatFileStore.cpp create mode 100644 sources/ChatView/ChatFileStore.hpp delete mode 100644 sources/ChatView/ChatHistoryBridge.cpp delete mode 100644 sources/ChatView/ChatHistoryBridge.hpp delete mode 100644 sources/ChatView/ChatHistoryStore.cpp delete mode 100644 sources/ChatView/ChatHistoryStore.hpp delete mode 100644 sources/ChatView/ChatSerializer.cpp delete mode 100644 sources/ChatView/ChatSerializer.hpp create mode 100644 sources/ChatView/ConversationCoordinator.cpp create mode 100644 sources/ChatView/ConversationCoordinator.hpp create mode 100644 sources/ChatView/ConversationPorts.hpp delete mode 100644 sources/ChatView/icons/link-file-dark.svg delete mode 100644 sources/ChatView/icons/link-file-light.svg create mode 100644 sources/ChatView/qml/chatparts/BlockPayload.js create mode 100644 sources/ChatView/qml/chatparts/PermissionBlock.qml create mode 100644 sources/ChatView/qml/chatparts/PlanBlock.qml create mode 100644 sources/ChatView/qml/controls/DropZone.qml delete mode 100644 sources/ChatView/qml/controls/SplitDropZone.qml create mode 100644 sources/acp/AcpChatBackend.cpp create mode 100644 sources/acp/AcpChatBackend.hpp create mode 100644 sources/acp/AgentBinding.cpp create mode 100644 sources/acp/AgentBinding.hpp create mode 100644 sources/acp/AgentCatalog.cpp create mode 100644 sources/acp/AgentCatalog.hpp create mode 100644 sources/acp/AgentCatalogStore.cpp create mode 100644 sources/acp/AgentCatalogStore.hpp create mode 100644 sources/acp/AgentDefinition.cpp create mode 100644 sources/acp/AgentDefinition.hpp create mode 100644 sources/acp/AgentKnowledgeService.hpp create mode 100644 sources/acp/AgentLaunch.cpp create mode 100644 sources/acp/AgentLaunch.hpp create mode 100644 sources/acp/AgentRegistryParser.cpp create mode 100644 sources/acp/AgentRegistryParser.hpp create mode 100644 sources/acp/AgentSpawn.cpp create mode 100644 sources/acp/AgentSpawn.hpp create mode 100644 sources/acp/AgentTester.cpp create mode 100644 sources/acp/AgentTester.hpp create mode 100644 sources/acp/CMakeLists.txt create mode 100644 sources/acp/ChatPermissionProvider.cpp create mode 100644 sources/acp/ChatPermissionProvider.hpp rename sources/context/{ChangesManager.cpp => FileEditManager.cpp} (95%) rename sources/context/{ChangesManager.h => FileEditManager.hpp} (87%) delete mode 100644 sources/context/RulesLoader.cpp delete mode 100644 sources/context/RulesLoader.hpp create mode 100644 sources/mcp/AgentKnowledgeServer.cpp create mode 100644 sources/mcp/AgentKnowledgeServer.hpp create mode 100644 sources/session/AgentPlan.cpp create mode 100644 sources/session/AgentPlan.hpp create mode 100644 sources/session/BlockCodec.cpp create mode 100644 sources/session/BlockCodec.hpp create mode 100644 sources/session/FencedText.cpp create mode 100644 sources/session/FencedText.hpp create mode 100644 sources/session/PermissionRequest.cpp create mode 100644 sources/session/PermissionRequest.hpp create mode 100644 sources/session/TurnLedger.cpp create mode 100644 sources/session/TurnLedger.hpp delete mode 100644 sources/settings/AgentRole.cpp delete mode 100644 sources/settings/AgentRole.hpp delete mode 100644 sources/settings/AgentRoleDialog.cpp delete mode 100644 sources/settings/AgentRoleDialog.hpp delete mode 100644 sources/settings/AgentRolesWidget.cpp delete mode 100644 sources/settings/AgentRolesWidget.hpp create mode 100644 sources/settings/AgentsSettings.cpp create mode 100644 sources/settings/AgentsSettings.hpp create mode 100644 sources/settings/AgentsWidget.cpp create mode 100644 sources/settings/AgentsWidget.hpp create mode 100644 sources/tools/EditorStateTools.cpp create mode 100644 sources/tools/EditorStateTools.hpp create mode 100644 tests/FakeAcpAgent.hpp create mode 100644 tests/FakeLlmProvider.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ff59e9..1daeece 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,6 +64,7 @@ add_qtc_plugin(QodeAssist LLMQore Skills QodeAssistSession + QodeAssistAcp QodeAssistChatViewplugin SOURCES .github/workflows/build_cmake.yml @@ -167,10 +168,14 @@ add_qtc_plugin(QodeAssist sources/tools/TodoTool.hpp sources/tools/TodoTool.cpp sources/tools/ReadOriginalHistoryTool.hpp sources/tools/ReadOriginalHistoryTool.cpp sources/tools/SkillTool.hpp sources/tools/SkillTool.cpp + sources/tools/EditorStateTools.hpp sources/tools/EditorStateTools.cpp + sources/mcp/AgentKnowledgeServer.hpp sources/mcp/AgentKnowledgeServer.cpp sources/mcp/McpServerManager.hpp sources/mcp/McpServerManager.cpp sources/mcp/McpServerConnection.hpp sources/mcp/McpServerConnection.cpp sources/mcp/McpClientsManager.hpp sources/mcp/McpClientsManager.cpp sources/settings/McpClientsListAspect.hpp sources/settings/McpClientsListAspect.cpp + sources/settings/AgentsSettings.hpp sources/settings/AgentsSettings.cpp + sources/settings/AgentsWidget.hpp sources/settings/AgentsWidget.cpp ) target_include_directories(QodeAssist PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/sources) @@ -180,6 +185,8 @@ extend_qtc_plugin(QodeAssist DEPENDS Qt::Test SOURCES tests/QodeAssistTest.hpp tests/QodeAssistTest.cpp + tests/FakeAcpAgent.hpp + tests/FakeLlmProvider.hpp ) if(WITH_TESTS) diff --git a/QodeAssist.qrc b/QodeAssist.qrc index f75afc4..6906e4e 100644 --- a/QodeAssist.qrc +++ b/QodeAssist.qrc @@ -10,5 +10,6 @@ resources/images/suggest-new-icon@2x.png resources/images/qode-assist-chat-icon.png resources/images/qode-assist-chat-icon@2x.png + resources/agents/acp-registry-snapshot.json diff --git a/README.md b/README.md index d83740e..d564139 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,10 @@ QodeAssist enhances Qt Creator with AI-powered coding assistance: - **Agent Skills** — reusable folders of specialized instructions loaded on demand; discovered from `.qodeassist/skills/` and `.claude/skills/`, invoked automatically, with `/skill`, or always-on - **MCP Server** — expose QodeAssist's project-aware tools to external MCP clients (Claude Code, VS Code, Claude Desktop via bridge) - **MCP Client Hub** — connect QodeAssist to external MCP servers and use their tools in Chat and Quick Refactor (authenticated MCP servers are not supported yet) -- **File Context** — attach, link, or auto-sync open editor files for richer prompts +- **File Context** — attach files and images to your messages for richer prompts - **Many Providers** — Ollama, llama.cpp, LM Studio (Chat + Responses), Claude, OpenAI (Chat + Responses), Google AI, Mistral, Codestral, OpenRouter, Qwen (OpenAI + Responses), DeepSeek, any OpenAI-compatible endpoint - **Reasoning / Thinking** — streamed chain-of-thought is shown for reasoning models across Claude, Google, OpenAI Responses, and any OpenAI-compatible endpoint that returns `reasoning_content` (DeepSeek, Qwen QwQ/Qwen3-Thinking, LM Studio, OpenRouter, …) -- **Customizable** — per-project rules (`.qodeassist/rules/`), agent roles, reusable refactor templates, full prompt-template control +- **Customizable** — per-project and global agent skills, reusable refactor templates, full prompt-template control **Join our [Discord Community](https://discord.gg/BGMkUsXUgf)** to get support and connect with other users! @@ -217,9 +217,7 @@ For optimal coding assistance, we recommend using these top-tier models: ### Additional Configuration -- **[Agent Roles](docs/agent-roles.md)** - Create AI personas with specialized system prompts - **[Chat Summarization](docs/chat-summarization.md)** - Compress conversations to save context tokens -- **[Project Rules](docs/project-rules.md)** - Customize AI behavior for your project - **[Ignoring Files](docs/ignoring-files.md)** - Exclude files from context using `.qodeassistignore` ## Features @@ -255,10 +253,8 @@ Configure in: `Tools → Options → QodeAssist → Code Completion → General - Multiple chat panels: side panel, bottom panel, and popup window - Chat history with auto-save and restore - Token usage monitoring -- **[Agent Roles](docs/agent-roles.md)** - Switch between AI personas (Developer, Reviewer, custom roles) - **[Chat Summarization](docs/chat-summarization.md)** - Compress long conversations into AI-generated summaries -- **[File Context](docs/file-context.md)** - Attach or link files for better context -- Automatic syncing with open editor files (optional) +- **[File Context](docs/file-context.md)** - Attach files for better context - Extended thinking / reasoning mode - shows streamed chain-of-thought for reasoning models (Claude, Google, OpenAI Responses, and OpenAI-compatible endpoints returning `reasoning_content` such as DeepSeek, Qwen, LM Studio, OpenRouter) ### Quick Refactoring @@ -350,15 +346,13 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ Examples: Codestral, Qwen2.5-Coder, DeepSeek-Coder │ │ │ │ 1. System Prompt (from Code Completion Settings - FIM variant) │ -│ 2. Project Rules: │ -│ └─ .qodeassist/rules/completion/*.md │ -│ 3. Open Files Context (optional, if enabled): │ +│ 2. Open Files Context (optional, if enabled): │ │ └─ Currently open editor files │ -│ 4. Code Context: │ +│ 3. Code Context: │ │ ├─ Code before cursor (prefix) │ │ └─ Code after cursor (suffix) │ │ │ -│ Final Prompt: FIM_Template(Prefix: SystemPrompt + Rules + OpenFiles + │ +│ Final Prompt: FIM_Template(Prefix: SystemPrompt + OpenFiles + │ │ CodeBefore, │ │ Suffix: CodeAfter) │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -377,18 +371,16 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ │ │ 1. System Prompt (from Code Completion Settings - Non-FIM variant) │ │ └─ Includes response formatting instructions │ -│ 2. Project Rules: │ -│ └─ .qodeassist/rules/completion/*.md │ -│ 3. Open Files Context (optional, if enabled): │ +│ 2. Open Files Context (optional, if enabled): │ │ └─ Currently open editor files │ -│ 4. Code Context: │ +│ 3. Code Context: │ │ ├─ File information (language, path) │ │ ├─ Code before cursor │ │ ├─ marker │ │ └─ Code after cursor │ -│ 5. User Message: "Complete the code at cursor position" │ +│ 4. User Message: "Complete the code at cursor position" │ │ │ -│ Final Prompt: [System: SystemPrompt + Rules] │ +│ Final Prompt: [System: SystemPrompt] │ │ [User: OpenFiles + Context + CompletionRequest] │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -403,15 +395,10 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ CHAT ASSISTANT │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 1. System Prompt (from Chat Assistant Settings) │ -│ 2. Agent Role (optional, from role selector): │ -│ └─ Role-specific system prompt (Developer, Reviewer, custom) │ -│ 3. Project Rules: │ -│ ├─ .qodeassist/rules/common/*.md │ -│ └─ .qodeassist/rules/chat/*.md │ +│ 2. Project Info (active project name, source root, build directory) │ +│ 3. Skills (always-on bodies + catalog + /invoked skills) │ │ 4. File Context (optional): │ -│ ├─ Attached files (manual) │ -│ ├─ Linked files (persistent) │ -│ └─ Open editor files (if auto-sync enabled) │ +│ └─ Attached files (manual) │ │ 5. Tool Definitions (if enabled): │ │ ├─ ReadProjectFileByName │ │ ├─ ListProjectFiles │ @@ -420,7 +407,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ 6. Conversation History │ │ 7. User Message │ │ │ -│ Final Prompt: [System: SystemPrompt + AgentRole + Rules + Tools] │ +│ Final Prompt: [System: SystemPrompt + ProjectInfo + Skills + Tools] │ │ [History: Previous messages] │ │ [User: FileContext + UserMessage] │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -436,10 +423,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ QUICK REFACTORING │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 1. System Prompt (from Quick Refactor Settings) │ -│ 2. Project Rules: │ -│ ├─ .qodeassist/rules/common/*.md │ -│ └─ .qodeassist/rules/quickrefactor/*.md │ -│ 3. Code Context: │ +│ 2. Code Context: │ │ ├─ File information (language, path) │ │ ├─ Code before selection (configurable amount) │ │ ├─ marker │ @@ -447,15 +431,15 @@ QodeAssist uses a flexible prompt composition system that adapts to different co │ ├─ marker │ │ ├─ marker (position within selection) │ │ └─ Code after selection (configurable amount) │ -│ 4. Refactor Instruction: │ +│ 3. Refactor Instruction: │ │ ├─ Built-in (e.g., "Improve Code", "Alternative Solution") │ │ ├─ Custom Instruction (from library) │ │ │ └─ ~/.config/QtProject/qtcreator/qodeassist/ │ │ │ quick_refactor/instructions/*.json │ │ └─ Additional Details (optional user input) │ -│ 5. Tool Definitions (if enabled) │ +│ 4. Tool Definitions (if enabled) │ │ │ -│ Final Prompt: [System: SystemPrompt + Rules] │ +│ Final Prompt: [System: SystemPrompt] │ │ [User: Context + Markers + Instruction + Details] │ └─────────────────────────────────────────────────────────────────────────────┘ ``` @@ -464,9 +448,8 @@ QodeAssist uses a flexible prompt composition system that adapts to different co ### Key Points -- **Project Rules** are automatically loaded from `.qodeassist/rules/` directory structure - **System Prompts** are configured independently for each feature in Settings -- **Agent Roles** add role-specific prompts on top of the base system prompt (Chat only) +- **Skills** provide reusable, project- or globally-scoped instructions for Chat: always-on skills are injected into the system prompt, others are invoked with `/skill-name` - **FIM vs Non-FIM models** for code completion use different System Prompts: - FIM models: Direct completion prompt - Non-FIM models: Prompt includes response formatting instructions @@ -474,7 +457,7 @@ QodeAssist uses a flexible prompt composition system that adapts to different co - **Custom Instructions** provide reusable templates that can be augmented with specific details - **Tool Calling** is available for Chat and Quick Refactor when enabled -See [Project Rules Documentation](docs/project-rules.md), [Agent Roles Guide](docs/agent-roles.md), and [Quick Refactoring Guide](docs/quick-refactoring.md) for more details. +See the [Quick Refactoring Guide](docs/quick-refactoring.md) for more details. ## QtCreator Version Compatibility @@ -522,7 +505,6 @@ For additional support, join our [Discord Community](https://discord.gg/BGMkUsXU - [x] Diff sharing with models - [x] Tools / function calling (file I/O, build, terminal, diagnostics) - [x] Agent Skills (project + global directories, `/skill` commands, always-on, `load_skill` tool) -- [x] Project-specific rules (`.qodeassist/rules/`) - [x] MCP (Model Context Protocol) — QodeAssist as a server - [x] MCP — QodeAssist as a client (consume external MCP tools; authenticated MCP servers not yet supported) - [ ] Full project source sharing diff --git a/docs/acp-agents.md b/docs/acp-agents.md new file mode 100644 index 0000000..8484d0b --- /dev/null +++ b/docs/acp-agents.md @@ -0,0 +1,114 @@ +# ACP agents + +QodeAssist can talk to external coding agents that speak the +[Agent Client Protocol](https://agentclientprotocol.com) (ACP). The **QodeAssist > +Agents** settings page lists the agents it knows about and lets you verify that one +starts and answers the ACP handshake. + +## Where the list comes from + +The list is merged from three sources. When the same agent `id` appears in more than +one, the higher entry wins: + +1. **Your JSON files** in `qodeassist/agents/` inside the Qt Creator user resource + directory (**Open Agents Folder...** opens it). Press **Reload** after editing. +2. **The ACP registry**, downloaded from + `https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json` when you press + **Refresh from Registry** and cached on disk. There is no background polling: the + list only changes when you ask for it. +3. **A bundled snapshot**, so the list is never empty on a fresh install or offline. + +If a download fails, the cached copy and the bundled snapshot stay in place. + +## Finding the agent's executable + +Qt Creator started from the Dock or a launcher does not inherit your shell's `PATH`, so +`npx` or `uvx` installed through Homebrew, nvm or uv is often invisible to it — the agent +then fails to start with `execve: No such file or directory`. **Extra PATH for launching +agents** on the Agents page lists directories that are both searched for the executable +and prepended to the agent's own `PATH`. On macOS it defaults to +`/opt/homebrew/bin:/usr/local/bin`. An `env` entry in the agent definition still wins over +it. + +## Credentials and other environment variables + +Some agents authenticate through an environment variable rather than the ACP handshake — +the Claude adapter, for instance, declares no `authMethods` at all and reads +`CLAUDE_CODE_OAUTH_TOKEN`. Since a Qt Creator started from the dock inherits no shell +environment, that variable never reaches the agent and it fails with an expired-session +error. + +**Forward these variables to agents** on the Agents page takes variable *names*, not +values, and defaults to `CLAUDE_CODE_OAUTH_TOKEN`. For each name QodeAssist uses the value +from its own environment when it has one — which covers Windows, and any platform when Qt +Creator was launched from a terminal. On macOS and Linux the remaining names are read once +per session from a login shell (`$SHELL -l -i -c env`), so a token exported in your shell +profile works without being copied anywhere. Nothing is stored in the settings but the +names. + +An `env` entry in the agent definition still wins over a forwarded variable. + +## Distributions + +An entry describes how the agent is started: + +- `npx` — launched as `npx -y `. Requires Node.js on `PATH`. +- `uvx` — launched as `uvx `. Requires uv on `PATH`. +- `command` — launched directly. This is the QodeAssist extension used by your own + JSON files. +- `binary` — a downloadable platform archive. QodeAssist does not download binaries, + so these agents are listed but cannot be started. Install the agent yourself and add + a `command` entry for it. + +## Defining your own agent + +Create a `.json` file in the agents folder. A file holds either a single agent object +or a registry-shaped `{"agents": [...]}` document. + +```json +{ + "id": "my-agent", + "name": "My Agent", + "version": "1.0.0", + "description": "Locally installed agent", + "distribution": { + "command": { + "cmd": "/usr/local/bin/my-agent", + "args": ["acp"], + "env": { "MY_AGENT_LOG": "debug" } + } + } +} +``` + +Reusing an `id` from the registry overrides that entry — which is how you make a +`binary` agent launchable after installing it manually: + +```json +{ + "id": "cursor", + "name": "Cursor", + "distribution": { + "command": { "cmd": "/usr/local/bin/cursor-agent", "args": ["acp"] } + } +} +``` + +Agent definitions hold no secrets. Agents that need credentials authenticate through +the ACP handshake. + +## Testing an agent + +Select an agent and press **Test**. QodeAssist starts the process, runs the ACP +`initialize` handshake and reports the agent's name, version, protocol version, +whether it supports session persistence (`loadSession`), its prompt and MCP +capabilities, and its authentication methods. On failure it shows the error together +with the agent's own output. + +## Long conversations and reopened sessions + +When an agent conversation grows long, the **Hand over** button in the chat bottom +bar summarizes the transcript with your Chat Assistant configuration and continues in +a fresh agent session seeded with that summary — see +[chat-summarization.md](chat-summarization.md) for details. The same handover is +offered on the read-only banner when a saved agent session can no longer be resumed. diff --git a/docs/agent-roles.md b/docs/agent-roles.md deleted file mode 100644 index c8195a5..0000000 --- a/docs/agent-roles.md +++ /dev/null @@ -1,174 +0,0 @@ -# Agent Roles - -Agent Roles allow you to define different AI personas with specialized system prompts for various tasks. Switch between roles instantly in the chat interface to adapt the AI's behavior to your current needs. - -## Overview - -Agent Roles are reusable system prompt configurations that modify how the AI assistant responds. Instead of manually changing system prompts, you can create roles like "Developer", "Code Reviewer", or "Documentation Writer" and switch between them with a single click. - -**Key Features:** -- **Quick Switching**: Change roles from the chat toolbar dropdown -- **Custom Prompts**: Each role has its own specialized system prompt -- **Built-in Roles**: Pre-configured Developer and Code Reviewer roles -- **Persistent**: Roles are saved locally and loaded on startup -- **Extensible**: Create unlimited custom roles for different tasks - -## Default Roles - -QodeAssist comes with three built-in roles: - -### Developer -Experienced Qt/C++ developer with a structured workflow: analyze the problem, propose a solution, wait for approval, then implement. Best for implementation tasks where you want thoughtful, minimal code changes. - -### Code Reviewer -Expert C++/QML code reviewer specializing in C++20 and Qt6. Checks for bugs, memory leaks, thread safety, Qt patterns, and production readiness. Provides direct, specific feedback with code examples. - -### Researcher -Research-oriented developer who investigates problems and explores solutions. Analyzes problems, presents multiple approaches with trade-offs, and recommends the best option. Does not write implementation code — focuses on helping you make informed decisions. - -## Using Agent Roles - -### Switching Roles in Chat - -1. Open the Chat Assistant (side panel, bottom panel, or popup window) -2. Locate the **Role selector** dropdown in the top toolbar (next to the configuration selector) -3. Select a role from the dropdown -4. The AI will now use the selected role's system prompt - -**Note**: Selecting "No Role" uses only the base system prompt without role specialization. - -### Viewing Active Role - -Click the **Context** button (📋) in the chat toolbar to view: -- Base system prompt -- Current agent role and its system prompt -- Active project rules - -## Managing Agent Roles - -### Opening the Role Manager - -Navigate to: `Qt Creator → Preferences → QodeAssist → Chat Assistant` - -Scroll down to the **Agent Roles** section where you can manage all your roles. - -### Creating a New Role - -1. Click **Add...** button -2. Fill in the role details: - - **Name**: Display name shown in the dropdown (e.g., "Documentation Writer") - - **ID**: Unique identifier for the role file (e.g., "doc_writer") - - **Description**: Brief explanation of the role's purpose - - **System Prompt**: The specialized instructions for this role -3. Click **OK** to save - -### Editing a Role - -1. Select a role from the list -2. Click **Edit...** or double-click the role -3. Modify the fields as needed -4. Click **OK** to save changes - -**Note**: Built-in roles cannot be edited directly. Duplicate them to create a modifiable copy. - -### Duplicating a Role - -1. Select a role to duplicate -2. Click **Duplicate...** -3. Modify the copy as needed -4. Click **OK** to save as a new role - -### Deleting a Role - -1. Select a custom role (built-in roles cannot be deleted) -2. Click **Delete** -3. Confirm deletion - -## Creating Effective Roles - -### System Prompt Tips - -- **Be specific**: Clearly define the role's expertise and focus areas -- **Set expectations**: Describe the desired response format and style -- **Include guidelines**: Add specific rules or constraints for responses -- **Use structured prompts**: Break down complex roles into bullet points - -## Storage Location - -Agent roles are stored as JSON files in: - -``` -~/.config/QtProject/qtcreator/qodeassist/agent_roles/ -``` - -**On different platforms:** -- **Linux**: `~/.config/QtProject/qtcreator/qodeassist/agent_roles/` -- **macOS**: `~/Library/Application Support/QtProject/Qt Creator/qodeassist/agent_roles/` -- **Windows**: `%APPDATA%\QtProject\qtcreator\qodeassist\agent_roles\` - -### File Format - -Each role is stored as a JSON file named `{id}.json`: - -```json -{ - "id": "doc_writer", - "name": "Documentation Writer", - "description": "Technical documentation and code comments", - "systemPrompt": "You are a technical documentation specialist...", - "isBuiltin": false -} -``` - -### Manual Editing - -You can: -- Edit JSON files directly in any text editor -- Copy role files between machines -- Share roles with team members -- Version control your roles -- Click **Open Roles Folder...** to quickly access the directory - -## How Roles Work - -When a role is selected, the final system prompt is composed as: - -``` -┌─────────────────────────────────────────────────┐ -│ Final System Prompt = Base Prompt + Role Prompt │ -├─────────────────────────────────────────────────┤ -│ 1. Base System Prompt (from Chat Settings) │ -│ 2. Agent Role System Prompt │ -│ 3. Project Rules (common/ + chat/) │ -│ 4. Linked Files Context │ -└─────────────────────────────────────────────────┘ -``` - -This allows roles to augment rather than replace your base configuration. - -## Best Practices - -1. **Keep roles focused**: Each role should have a clear, specific purpose -2. **Use descriptive names**: Make it easy to identify roles at a glance -3. **Test your prompts**: Verify roles produce the expected behavior -4. **Iterate and improve**: Refine prompts based on AI responses -5. **Share with team**: Export and share useful roles with colleagues - -## Troubleshooting - -### Role Not Appearing in Dropdown -- Restart Qt Creator after adding roles manually -- Check JSON file format validity -- Verify file is in the correct directory - -### Role Behavior Not as Expected -- Review the system prompt for clarity -- Check if base system prompt conflicts with role prompt -- Try a more specific or detailed prompt - -## Related Documentation - -- [Project Rules](project-rules.md) - Project-specific AI behavior customization -- [Chat Assistant Features](../README.md#chat-assistant) - Overview of chat functionality -- [File Context](file-context.md) - Attaching files to chat context - diff --git a/docs/chat-summarization.md b/docs/chat-summarization.md index a98d030..b659590 100644 --- a/docs/chat-summarization.md +++ b/docs/chat-summarization.md @@ -11,7 +11,7 @@ When conversations grow long, they consume more context tokens with each message - Important context for continuing the conversation **Key Features:** -- **One-click compression**: Summarize directly from the chat toolbar +- **One-click compression**: Summarize directly from the chat bottom bar - **Preserves original**: Creates a new chat file, keeping the original intact - **Smart summaries**: AI extracts the most relevant information - **Markdown formatted**: Summaries are well-structured and readable @@ -21,10 +21,23 @@ When conversations grow long, they consume more context tokens with each message ### Compressing a Chat 1. Open any chat with conversation history -2. Click the **Compress** button (📦) in the chat top bar +2. Click the **Compress** button (📦) in the chat bottom bar 3. Wait for the AI to generate the summary 4. A new chat opens with the compressed summary +### Agent Sessions: Hand Over + +In a chat bound to an ACP agent the same button reads **Hand over**. Instead of +writing a compressed chat file, it summarizes the transcript using your configured +Chat Assistant provider (the same LLM that powers compression) and starts a fresh +agent session with the summary as its opening context. The transcript in the chat +stays intact; only the agent-side session is replaced. + +The button is disabled with an explanatory tooltip when the Chat Assistant +configuration is incomplete (provider, model, template, or URL missing), because the +summary is generated by that configuration. Auto-compression never triggers for agent +sessions — the agent manages its own context window. + ### What Gets Preserved The summarization process: @@ -94,9 +107,12 @@ No additional configuration is required. ## Troubleshooting -### Compression Button Not Visible -- Ensure you have an active chat with messages -- Check that the chat top bar is visible +### Compression Button Disabled +- Hover the button: the tooltip explains why (for example, the Chat Assistant + configuration is unassigned, or an agent chat has nothing to summarize yet) +- Assign the chat feature in Preferences → QodeAssist if the tooltip says so +- While a compression or summary is running, the button is replaced by a progress + indicator with a Cancel action in the bottom bar ### Compression Fails - Verify your Chat Assistant provider is configured correctly @@ -110,6 +126,4 @@ No additional configuration is required. ## Related Documentation -- [Agent Roles](agent-roles.md) - Switch between AI personas - [File Context](file-context.md) - Attach files to chat -- [Project Rules](project-rules.md) - Customize AI behavior diff --git a/docs/file-context.md b/docs/file-context.md index 04892b8..7d0a0d2 100644 --- a/docs/file-context.md +++ b/docs/file-context.md @@ -1,6 +1,6 @@ # File Context Feature -QodeAssist provides two powerful ways to include source code files in your chat conversations: Attachments and Linked Files. Each serves a distinct purpose and helps provide better context for the AI assistant. +QodeAssist lets you include source code files in your chat conversations as attachments. ## Attached Files @@ -14,20 +14,8 @@ Attachments are designed for one-time code analysis and specific queries: - Quick implementation questions - Files can be attached using the paperclip icon in the chat interface - Multiple files can be attached to a single message +- Files can also be added by dragging them onto the chat panel or with `@` mentions -## Linked Files - -Linked files provide persistent context throughout the conversation: - -- Files remain accessible for the entire chat session -- Content is included in every message exchange -- Files are automatically refreshed - always using latest content from disk -- Perfect for: - - Long-term refactoring discussions - - Complex architectural changes - - Multi-file implementations - - Maintaining context across related questions -- Can be managed using the link icon in the chat interface -- Supports automatic syncing with open editor files (can be enabled in settings) -- Files can be added/removed at any time during the conversation - +For persistent, conversation-long context, enable tools (or use an ACP agent): the +model reads the files it needs from the project itself, always seeing the latest +content from disk. diff --git a/docs/project-rules.md b/docs/project-rules.md deleted file mode 100644 index b8bf9d3..0000000 --- a/docs/project-rules.md +++ /dev/null @@ -1,35 +0,0 @@ -# Project Rules Configuration - -QodeAssist supports project-specific rules to customize AI behavior for your codebase. Create a `.qodeassist/rules/` directory in your project root. - -## Quick Start - -```bash -mkdir -p .qodeassist/rules/{common,completion,chat,quickrefactor} -``` - -## Directory Structure - -``` -.qodeassist/ -└── rules/ - ├── common/ # Applied to all contexts - ├── completion/ # Code completion only - ├── chat/ # Chat assistant only - └── quickrefactor/ # Quick refactor only -``` - -All `.md` files in each directory are automatically loaded and added to the system prompt. - -## Example - -Create `.qodeassist/rules/common/general.md`: - -```markdown -# Project Guidelines -- Use snake_case for private members -- Prefix interfaces with 'I' -- Always document public APIs -- Prefer Qt containers over STL -``` - diff --git a/docs/quick-refactoring.md b/docs/quick-refactoring.md index 1357936..91e9cc4 100644 --- a/docs/quick-refactoring.md +++ b/docs/quick-refactoring.md @@ -206,7 +206,6 @@ The LLM receives: - **Cursor Position**: Marked with `` tag - **Selection Markers**: `` and `` tags - **Your Instructions**: Built-in, custom, or typed -- **Project Rules**: If configured (see [Project Rules](project-rules.md)) ### Context Configuration @@ -270,7 +269,6 @@ Fully local setup for offline or secure environments. ## Related Documentation -- [Project Rules](project-rules.md) - Project-specific AI behavior customization - [File Context](file-context.md) - Attaching files to chat context - [Ignoring Files](ignoring-files.md) - Exclude files from AI context - [Provider Configuration](../README.md#configuration) - Setting up LLM providers diff --git a/resources/agents/acp-registry-snapshot.json b/resources/agents/acp-registry-snapshot.json new file mode 100644 index 0000000..050caa2 --- /dev/null +++ b/resources/agents/acp-registry-snapshot.json @@ -0,0 +1,1351 @@ +{ + "version": "1.0.0", + "agents": [ + { + "id": "agoragentic-acp", + "name": "Agoragentic", + "version": "1.3.0", + "description": "Agent marketplace with 174+ AI capabilities. Browse, invoke, and pay for agent services settled in USDC on Base L2.", + "repository": "https://github.com/rhein1/agoragentic-integrations", + "website": "https://agoragentic.com", + "authors": [ + "ACRE / Agoragentic" + ], + "license": "MIT", + "distribution": { + "npx": { + "package": "agoragentic-mcp@1.3.0", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/agoragentic-acp.svg" + }, + { + "id": "amp-acp", + "name": "Amp", + "version": "0.8.1", + "description": "ACP wrapper for Amp - the frontier coding agent", + "repository": "https://github.com/tao12345666333/amp-acp", + "authors": [ + "tao12345666333" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/amp-acp.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-darwin-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-aarch64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-aarch64.tar.gz", + "cmd": "./amp-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-linux-x86_64.tar.gz", + "cmd": "./amp-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/tao12345666333/amp-acp/releases/download/v0.8.1/amp-acp-windows-x86_64.zip", + "cmd": "amp-acp.exe" + } + } + } + }, + { + "id": "auggie", + "name": "Auggie CLI", + "version": "0.33.0", + "description": "Augment Code's powerful software agent, backed by industry-leading context engine", + "repository": "https://github.com/augmentcode/auggie", + "website": "https://www.augmentcode.com/", + "authors": [ + "Augment Code " + ], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/auggie.svg", + "distribution": { + "npx": { + "package": "@augmentcode/auggie@0.33.0", + "args": [ + "--acp" + ], + "env": { + "AUGMENT_DISABLE_AUTO_UPDATE": "1" + } + } + } + }, + { + "id": "autohand", + "name": "Autohand Code", + "version": "0.2.1", + "description": "Autohand Code - AI coding agent powered by Autohand AI", + "repository": "https://github.com/autohandai/autohand-acp", + "website": "https://www.autohand.ai/cli/", + "authors": [ + "Autohand AI" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@autohandai/autohand-acp@0.2.1" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/autohand.svg" + }, + { + "id": "claude-acp", + "name": "Claude Agent", + "version": "0.59.0", + "description": "ACP wrapper for Anthropic's Claude", + "repository": "https://github.com/agentclientprotocol/claude-agent-acp", + "authors": [ + "Anthropic", + "Zed Industries", + "JetBrains" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@agentclientprotocol/claude-agent-acp@0.59.0" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg" + }, + { + "id": "cline", + "name": "Cline", + "version": "3.0.46", + "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", + "repository": "https://github.com/cline/cline", + "website": "https://cline.bot/cli", + "authors": [ + "Cline Bot Inc." + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cline.svg", + "distribution": { + "npx": { + "package": "cline@3.0.46", + "args": [ + "--acp" + ] + } + } + }, + { + "id": "codebuddy-code", + "name": "Codebuddy Code", + "version": "2.106.7", + "description": "Tencent Cloud's official intelligent coding tool", + "website": "https://www.codebuddy.cn/cli/", + "authors": [ + "Tencent Cloud" + ], + "license": "Proprietary", + "distribution": { + "npx": { + "package": "@tencent-ai/codebuddy-code@2.106.7", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codebuddy-code.svg" + }, + { + "id": "codex-acp", + "name": "Codex", + "version": "1.1.4", + "description": "ACP adapter for OpenAI's coding assistant", + "repository": "https://github.com/agentclientprotocol/codex-acp", + "authors": [ + "OpenAI", + "JetBrains s.r.o", + "Zed Industries" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@agentclientprotocol/codex-acp@1.1.4" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg" + }, + { + "id": "cortex-code", + "name": "Cortex Code", + "version": "1.0.73", + "description": "Snowflake's Cortex Code coding agent", + "repository": "https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code", + "authors": [ + "Snowflake" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-arm64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "darwin-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-darwin-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-darwin-amd64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "linux-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-amd64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "linux-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-linux-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-linux-arm64/cortex", + "args": [ + "acp", + "serve" + ] + }, + "windows-x86_64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-amd64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-amd64/cortex.exe", + "args": [ + "acp", + "serve" + ] + }, + "windows-aarch64": { + "archive": "https://sfc-repo.snowflakecomputing.com/cortex-code-cli/a4643c4278/1.0.73%2B180523.e6179a031de9/coco-1.0.73%2B180523.e6179a031de9-windows-arm64.tar.gz", + "cmd": "./coco-1.0.73+180523.e6179a031de9-windows-arm64/cortex.exe", + "args": [ + "acp", + "serve" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cortex-code.svg" + }, + { + "id": "corust-agent", + "name": "Corust Agent", + "version": "0.6.0", + "description": "Co-building with a seasoned Rust partner.", + "repository": "https://github.com/Corust-ai/corust-agent-release", + "website": "https://corust.ai/", + "authors": [ + "Corust AI " + ], + "license": "GPL-3.0-or-later", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-arm64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "darwin-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-darwin-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "linux-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-linux-x64.tar.gz", + "cmd": "./corust-agent-acp" + }, + "windows-x86_64": { + "archive": "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.6.0/agent-windows-x64.zip", + "cmd": "./corust-agent-acp.exe" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/corust-agent.svg" + }, + { + "id": "crow-cli", + "name": "crow-cli", + "version": "0.1.24", + "description": "Minimal ACP Native Coding Agent", + "repository": "https://github.com/crow-cli/crow-cli", + "website": "https://crow-ai.dev", + "authors": [ + "Thomas Wood" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-darwin-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-aarch64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-linux-x86_64.tar.gz", + "cmd": "./crow-cli", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/crow-cli/crow-cli/releases/download/v0.1.24/crow-cli-windows-x86_64.zip", + "cmd": "./crow-cli.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/crow-cli.svg" + }, + { + "id": "cursor", + "name": "Cursor", + "version": "2026.07.09", + "description": "Cursor's coding agent", + "website": "https://cursor.com/docs/cli/acp", + "authors": [ + "Cursor" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/darwin/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/arm64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/linux/x64/agent-cli-package.tar.gz", + "cmd": "./dist-package/cursor-agent", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/arm64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://downloads.cursor.com/lab/2026.07.09-a3815c0/windows/x64/agent-cli-package.zip", + "cmd": "./dist-package\\cursor-agent.cmd", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/cursor.svg" + }, + { + "id": "deepagents", + "name": "DeepAgents", + "version": "0.1.7", + "description": "Batteries-included AI coding and general purpose agent powered by LangChain.", + "repository": "https://github.com/langchain-ai/deepagentsjs", + "website": "https://docs.langchain.com/oss/javascript/deepagents/overview", + "authors": [ + "LangChain" + ], + "license": "MIT", + "distribution": { + "npx": { + "package": "deepagents-acp@0.1.7", + "args": [] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/deepagents.svg" + }, + { + "id": "devin", + "name": "Devin", + "version": "3000.1.27", + "description": "Devin CLI coding agent by Cognition", + "website": "https://docs.devin.ai/cli", + "authors": [ + "Cognition" + ], + "license": "proprietary", + "repository": "https://github.com/CognitionAI/devin-cli", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-aarch64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-x86_64-apple-darwin.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-aarch64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-x86_64-unknown-linux.tar.gz", + "cmd": "./bin/devin", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-aarch64-pc-windows.zip", + "cmd": "./bin\\devin.exe", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://static.devin.ai/cli/3000.1.27/devin-3000.1.27-x86_64-pc-windows.zip", + "cmd": "./bin\\devin.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/devin.svg" + }, + { + "id": "dimcode", + "name": "DimCode", + "version": "0.2.31", + "description": "A coding agent that puts leading models at your command.", + "website": "https://dimcode.dev/docs/acp.html", + "authors": [ + "ArcShips" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "dimcode@0.2.31", + "args": [ + "acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dimcode.svg" + }, + { + "id": "dirac", + "name": "Dirac", + "version": "0.4.19", + "description": "Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.", + "repository": "https://github.com/dirac-run/dirac", + "website": "https://dirac.run", + "authors": [ + "Dirac Delta Labs" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/dirac.svg", + "distribution": { + "npx": { + "package": "dirac-cli@0.4.19", + "args": [ + "--acp" + ] + } + } + }, + { + "id": "factory-droid", + "name": "Factory Droid", + "version": "0.175.0", + "description": "Factory Droid - AI coding agent powered by Factory AI", + "website": "https://factory.ai/product/cli", + "authors": [ + "Factory AI" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "droid@0.175.0", + "args": [ + "exec", + "--output-format", + "acp-daemon" + ], + "env": { + "DROID_DISABLE_AUTO_UPDATE": "true", + "FACTORY_DROID_AUTO_UPDATE_ENABLED": "false" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/factory-droid.svg" + }, + { + "id": "fast-agent", + "name": "fast-agent", + "version": "0.9.16", + "description": "Code and build agents with comprehensive multi-provider support", + "repository": "https://github.com/evalstate/fast-agent", + "website": "https://fast-agent.ai", + "authors": [ + "enquiries@fast-agent.ai" + ], + "license": "Apache 2.0", + "distribution": { + "uvx": { + "package": "fast-agent-acp==0.9.16", + "args": [ + "-x" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/fast-agent.svg" + }, + { + "id": "gemini", + "name": "Gemini CLI", + "version": "0.51.0", + "description": "Google's official CLI for Gemini", + "repository": "https://github.com/google-gemini/gemini-cli", + "website": "https://geminicli.com", + "authors": [ + "Google" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@google/gemini-cli@0.51.0", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/gemini.svg" + }, + { + "id": "github-copilot-cli", + "name": "GitHub Copilot", + "version": "1.0.71", + "description": "GitHub's AI pair programmer", + "repository": "https://github.com/github/copilot-cli", + "website": "https://github.com/features/copilot/cli/", + "authors": [ + "GitHub" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@github/copilot@1.0.71", + "args": [ + "--acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/github-copilot-cli.svg" + }, + { + "id": "glm-acp-agent", + "name": "GLM Agent", + "version": "1.2.0", + "description": "ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.", + "repository": "https://github.com/stefandevo/glm-acp-agent", + "authors": [ + "Stefan de Vogelaere" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/glm-acp-agent.svg", + "distribution": { + "npx": { + "package": "glm-acp-agent@1.2.0" + } + } + }, + { + "id": "goose", + "name": "goose", + "version": "1.43.0", + "description": "A local, extensible, open source AI agent that automates engineering tasks", + "repository": "https://github.com/block/goose", + "website": "https://block.github.io/goose/", + "authors": [ + "Block" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-aarch64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ], + "sha256": "c24ffdd8a3863288592ab211758d41f0a779428f77e92fcaa73e9882899a4eec" + }, + "darwin-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-x86_64-apple-darwin.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ], + "sha256": "6d0fad77f52f8177a3c3f8f40b8af3260a4ec057c06cfd8dcaf3af1b39de1e78" + }, + "linux-aarch64": { + "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-aarch64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ], + "sha256": "5d2e93b7c740409ce09782d98b70db87eac2e1fcff527bdbaf44a98da8b3b597" + }, + "linux-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-x86_64-unknown-linux-gnu.tar.bz2", + "cmd": "./goose", + "args": [ + "acp" + ], + "sha256": "b218b08c0a86c9f356623f51bb91fc14c4de6667bc9c15d6d922778beb20f1bf" + }, + "windows-x86_64": { + "archive": "https://github.com/block/goose/releases/download/v1.43.0/goose-x86_64-pc-windows-msvc.zip", + "cmd": "./goose-package\\goose.exe", + "args": [ + "acp" + ], + "sha256": "e76ddbaa331f54f837e7697108f901f09d7e56b07e81c1b7fa41da9e58ba5a7a" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/goose.svg" + }, + { + "id": "grok-build", + "name": "Grok Build", + "version": "0.2.106", + "description": "xAI's coding agent and CLI", + "website": "https://x.ai/cli", + "authors": [ + "xAI" + ], + "license": "proprietary", + "distribution": { + "npx": { + "package": "@xai-official/grok@0.2.106", + "args": [ + "agent", + "stdio" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/grok-build.svg" + }, + { + "id": "harn", + "name": "Harn", + "version": "0.10.28", + "description": "Harn runs .harn agent pipelines as a native ACP coding agent over stdio.", + "repository": "https://github.com/burin-labs/harn", + "website": "https://harnlang.com", + "authors": [ + "Burin Labs" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-aarch64-apple-darwin.tar.gz", + "cmd": "./harn", + "args": [ + "serve", + "acp" + ], + "sha256": "1416f454593e89e93a12ef3b06af6ef0b132e0347459a2a27a9da5aad8cef409" + }, + "darwin-x86_64": { + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-x86_64-apple-darwin.tar.gz", + "cmd": "./harn", + "args": [ + "serve", + "acp" + ], + "sha256": "80857c07cdf474fa470517fc1b1271e82bca3c38eb0b3f8c545670fea22a51e4" + }, + "linux-aarch64": { + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./harn", + "args": [ + "serve", + "acp" + ], + "sha256": "85f533acb4ef039e853e28c29d3429ba24aebb2b77ea35eab0989a2702896c54" + }, + "linux-x86_64": { + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./harn", + "args": [ + "serve", + "acp" + ], + "sha256": "125b8b16d649c2e361777751294dd9f6d80c89bbb0b48813f4f3bbcba57e8a4e" + }, + "windows-x86_64": { + "archive": "https://github.com/burin-labs/harn/releases/download/v0.10.28/harn-x86_64-pc-windows-msvc.zip", + "cmd": "harn.exe", + "args": [ + "serve", + "acp" + ], + "sha256": "b698ce750925dcf061c5149f37315ddf1806278d39955d8e36fa64ca2ad77af2" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/harn.svg" + }, + { + "id": "junie", + "name": "Junie", + "version": "2144.9.0", + "description": "AI Coding Agent by JetBrains", + "repository": "https://github.com/JetBrains/junie", + "website": "https://junie.jetbrains.com", + "authors": [ + "JetBrains" + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-aarch64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": [ + "--acp=true" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-macos-amd64.zip", + "cmd": "./Applications/junie.app/Contents/MacOS/junie", + "args": [ + "--acp=true" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-aarch64.zip", + "cmd": "./junie-app/bin/junie", + "args": [ + "--acp=true" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-linux-amd64.zip", + "cmd": "./junie-app/bin/junie", + "args": [ + "--acp=true" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/JetBrains/junie/releases/download/2144.9/junie-release-2144.9-windows-amd64.zip", + "cmd": "./junie/junie.exe", + "args": [ + "--acp=true" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/junie.svg" + }, + { + "id": "kilo", + "name": "Kilo", + "version": "7.4.11", + "description": "The open source coding agent", + "repository": "https://github.com/Kilo-Org/kilocode", + "website": "https://kilo.ai/", + "authors": [ + "Kilo Code" + ], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kilo.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-darwin-arm64.zip", + "cmd": "./kilo", + "args": [ + "acp" + ], + "sha256": "14a030a354f3b51f0241662627702e7b06cddf3fcb6e0f1415279e9d3a3b8998" + }, + "darwin-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-darwin-x64.zip", + "cmd": "./kilo", + "args": [ + "acp" + ], + "sha256": "66e302e09b96fd9794012bdb608622b589e4810ba31eca35918d8acd1a32a438" + }, + "linux-aarch64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-linux-arm64.tar.gz", + "cmd": "./kilo", + "args": [ + "acp" + ], + "sha256": "48c5405eb05efb3558d120b521d1a2b097cd8e99d36d7caf015e7c467922793c" + }, + "linux-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-linux-x64.tar.gz", + "cmd": "./kilo", + "args": [ + "acp" + ], + "sha256": "b060dd4e094b0f9966de03d8a0d0d5cc8e6bb23ab04f1d04b7e3951d13079770" + }, + "windows-x86_64": { + "archive": "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.11/kilo-windows-x64.zip", + "cmd": "./kilo.exe", + "args": [ + "acp" + ], + "sha256": "1c04d25b1484526b1eb8abe44bbcfc179fb71b1efa88b0164cd5709ca8bb00e7" + } + }, + "npx": { + "package": "@kilocode/cli@7.4.11", + "args": [ + "acp" + ] + } + } + }, + { + "id": "kimi", + "name": "Kimi CLI", + "version": "1.49.0", + "description": "Moonshot AI's coding assistant", + "repository": "https://github.com/MoonshotAI/kimi-cli", + "website": "https://moonshotai.github.io/kimi-cli/", + "authors": [ + "Moonshot AI" + ], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-apple-darwin.tar.gz", + "cmd": "./kimi", + "args": [ + "acp" + ], + "sha256": "15018b20b203aee09658fdc64840c4846fc17c108d8dba1a19a95581d3ce2921" + }, + "linux-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": [ + "acp" + ], + "sha256": "5ac54cabce16ede27b9d2069b9b88edee25528646e7bb5befa9980a1ca71febb" + }, + "linux-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./kimi", + "args": [ + "acp" + ], + "sha256": "6ce0b83f583c45a64cc9f51ffe7e1a8e03ee79acda69945fcf8c23341b9d892f" + }, + "windows-aarch64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-aarch64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": [ + "acp" + ], + "sha256": "3ac8f05c7bd18d902a324c6c03a71084cfbe785b9669bbd556c071ee1d8f2f26" + }, + "windows-x86_64": { + "archive": "https://github.com/MoonshotAI/kimi-cli/releases/download/1.49.0/kimi-1.49.0-x86_64-pc-windows-msvc.zip", + "cmd": "./kimi.exe", + "args": [ + "acp" + ], + "sha256": "2acbbc7ca8c8ac4b03dab1d970f53a292bd226168151b423499feab9fc203ddd" + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/kimi.svg" + }, + { + "id": "minion-code", + "name": "Minion Code", + "version": "0.1.44", + "description": "An enhanced AI code assistant built on the Minion framework with rich development tools", + "repository": "https://github.com/femto/minion-code", + "authors": [ + "femto" + ], + "license": "AGPL-3.0", + "distribution": { + "uvx": { + "package": "minion-code@0.1.44", + "args": [ + "acp" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/minion-code.svg" + }, + { + "id": "mistral-vibe", + "name": "Mistral Vibe", + "version": "2.21.0", + "description": "Mistral's open-source coding assistant", + "repository": "https://github.com/mistralai/mistral-vibe", + "website": "https://mistral.ai/products/vibe", + "authors": [ + "Mistral AI" + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/mistral-vibe.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-darwin-aarch64-2.21.0.zip", + "cmd": "./vibe-acp", + "sha256": "243973c7bd69d4c5701ac4b5ce047df8dd1cd269df97d0b5c8ddb7fb64c3fd36" + }, + "darwin-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-darwin-x86_64-2.21.0.zip", + "cmd": "./vibe-acp", + "sha256": "77096fcf59c4369737ca77b1eb1cfede1dbf8d427bcc9e90f6f8bdbf45dae08b" + }, + "linux-aarch64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-linux-aarch64-2.21.0.zip", + "cmd": "./vibe-acp", + "sha256": "948d66eaa73d2c28d90b922cc6cc5afffb83a9aa4d50b4e3eaed811751238d56" + }, + "linux-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-linux-x86_64-2.21.0.zip", + "cmd": "./vibe-acp", + "sha256": "90792067c779f863b6c0d7dc6c5b06f50a9f82ac937cfd9276e35ad4e7d61690" + }, + "windows-x86_64": { + "archive": "https://github.com/mistralai/mistral-vibe/releases/download/v2.21.0/vibe-acp-windows-x86_64-2.21.0.zip", + "cmd": "./vibe-acp.exe", + "sha256": "4505cda1ff3d29c2493d1d1b261a84c677c1fb1106527b24d6ce9ca966b5f094" + } + } + } + }, + { + "id": "nova", + "name": "Nova", + "version": "1.1.27", + "description": "Nova by Compass AI - a fully-fledged software engineer at your command", + "repository": "https://github.com/Compass-Agentic-Platform/nova", + "website": "https://www.compassap.ai/portfolio/nova.html", + "authors": [ + "Compass AI" + ], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/nova.svg", + "distribution": { + "npx": { + "package": "@compass-ai/nova@1.1.27", + "args": [ + "acp" + ] + } + } + }, + { + "id": "opencode", + "name": "OpenCode", + "version": "1.18.3", + "description": "The open source coding agent", + "repository": "https://github.com/anomalyco/opencode", + "website": "https://opencode.ai", + "authors": [ + "Anomaly" + ], + "license": "MIT", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/opencode.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-arm64.zip", + "cmd": "./opencode", + "args": [ + "acp" + ], + "sha256": "946f62b155638b911144b7bef520ee4a6442f696297907873463bca3524e40ef" + }, + "darwin-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-darwin-x64.zip", + "cmd": "./opencode", + "args": [ + "acp" + ], + "sha256": "4ea147867ba19e4ec03559df557811f1674f40788aea4d10326dc563b7667c6d" + }, + "linux-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-arm64.tar.gz", + "cmd": "./opencode", + "args": [ + "acp" + ], + "sha256": "da0a631174eba380b2a1d51f9d364fa3812da433e72743c72471d4b5da59c69d" + }, + "linux-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-linux-x64.tar.gz", + "cmd": "./opencode", + "args": [ + "acp" + ], + "sha256": "60f27b2679f00a511b6539f97e02448afaf58d9c66e2448285ea0c517ca84583" + }, + "windows-aarch64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-arm64.zip", + "cmd": "./opencode", + "args": [ + "acp" + ], + "sha256": "a549fb2e9041db9438bcd9b77bfa0a4b2476caf2d550f37479aabfec1b079bfb" + }, + "windows-x86_64": { + "archive": "https://github.com/anomalyco/opencode/releases/download/v1.18.3/opencode-windows-x64.zip", + "cmd": "./opencode.exe", + "args": [ + "acp" + ], + "sha256": "68bc62930f6cb5755e0409aa9de0bb270a66ed2b8c9cf0c029e9f2287ed5486e" + } + } + } + }, + { + "id": "pi-acp", + "name": "pi ACP", + "version": "0.0.31", + "description": "ACP adapter for pi coding agent", + "repository": "https://github.com/svkozak/pi-acp", + "authors": [ + "Sergii Kozak " + ], + "license": "MIT", + "distribution": { + "npx": { + "package": "pi-acp@0.0.31" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/pi-acp.svg" + }, + { + "id": "poolside", + "name": "Poolside", + "version": "1.0.11", + "description": "Poolside's coding agent", + "repository": "https://github.com/poolsideai/pool", + "website": "https://poolside.ai", + "authors": [ + "Poolside " + ], + "license": "proprietary", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-arm64.tar.gz", + "cmd": "./pool-darwin-arm64", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-darwin-amd64.tar.gz", + "cmd": "./pool-darwin-amd64", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-arm64.tar.gz", + "cmd": "./pool-linux-arm64", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-linux-amd64.tar.gz", + "cmd": "./pool-linux-amd64", + "args": [ + "acp" + ] + }, + "windows-aarch64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-arm64.tar.gz", + "cmd": "./pool-windows-arm64.exe", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://downloads.poolside.ai/pool/v1.0.11/pool-windows-amd64.tar.gz", + "cmd": "./pool-windows-amd64.exe", + "args": [ + "acp" + ] + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/poolside.svg" + }, + { + "id": "qoder", + "name": "Qoder CLI", + "version": "0.2.14", + "description": "AI coding assistant with agentic capabilities", + "website": "https://qoder.com", + "authors": [ + "Qoder AI" + ], + "license": "proprietary", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qoder.svg", + "distribution": { + "npx": { + "package": "@qoder-ai/qodercli@0.2.14", + "args": [ + "--acp" + ] + } + } + }, + { + "id": "qwen-code", + "name": "Qwen Code", + "version": "0.20.0", + "description": "Alibaba's Qwen coding assistant", + "repository": "https://github.com/QwenLM/qwen-code", + "website": "https://qwenlm.github.io/qwen-code-docs/en/users/overview", + "authors": [ + "Alibaba Qwen Team" + ], + "license": "Apache-2.0", + "distribution": { + "npx": { + "package": "@qwen-code/qwen-code@0.20.0", + "args": [ + "--acp", + "--experimental-skills" + ] + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/qwen-code.svg" + }, + { + "id": "sigit", + "name": "siGit Code", + "version": "1.4.1", + "description": "Local-first coding agent. Runs entirely on your machine with optional on-device LLM inference via Onde.", + "repository": "https://github.com/getsigit/sigit", + "website": "https://github.com/getsigit/sigit", + "authors": [ + "smbCloud" + ], + "license": "Apache-2.0", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-macos-arm64.tar.gz", + "cmd": "./sigit" + }, + "darwin-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-macos-amd64.tar.gz", + "cmd": "./sigit" + }, + "linux-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-linux-arm64", + "cmd": "./sigit-linux-arm64" + }, + "linux-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-linux-amd64", + "cmd": "./sigit-linux-amd64" + }, + "windows-aarch64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-win-arm64.exe", + "cmd": "./sigit-win-arm64.exe" + }, + "windows-x86_64": { + "archive": "https://github.com/getsigit/sigit/releases/download/v1.4.1/sigit-win-amd64.exe", + "cmd": "./sigit-win-amd64.exe" + } + }, + "npx": { + "package": "@smbcloud/sigit@1.4.1" + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/sigit.svg" + }, + { + "id": "stakpak", + "name": "Stakpak", + "version": "0.3.88", + "description": "Open-source DevOps agent in Rust with enterprise-grade security", + "repository": "https://github.com/stakpak/agent", + "website": "https://stakpak.dev", + "authors": [ + "Stakpak Team " + ], + "license": "Apache-2.0", + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/stakpak.svg", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-aarch64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "darwin-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-darwin-x86_64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "linux-aarch64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-aarch64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "linux-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-linux-x86_64.tar.gz", + "cmd": "./stakpak", + "args": [ + "acp" + ] + }, + "windows-x86_64": { + "archive": "https://github.com/stakpak/agent/releases/download/v0.3.88/stakpak-windows-x86_64.zip", + "cmd": "./stakpak.exe", + "args": [ + "acp" + ] + } + } + } + }, + { + "id": "vtcode", + "name": "VT Code", + "version": "0.96.14", + "description": "An open-source coding agent with LLM-native code understanding and robust shell safety. Supports multiple LLM providers with automatic failover and efficient context management.", + "repository": "https://github.com/vinhnx/VTCode", + "website": "https://github.com/vinhnx/VTCode/blob/main/docs/guides/zed-acp.md", + "authors": [ + "vinhnx" + ], + "license": "MIT", + "distribution": { + "binary": { + "darwin-aarch64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-aarch64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "darwin-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-apple-darwin.tar.gz", + "cmd": "./vtcode", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "linux-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-unknown-linux-gnu.tar.gz", + "cmd": "./vtcode", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + }, + "windows-x86_64": { + "archive": "https://github.com/vinhnx/VTCode/releases/download/0.96.14/vtcode-0.96.14-x86_64-pc-windows-msvc.zip", + "cmd": "vtcode.exe", + "args": [ + "acp" + ], + "env": { + "VT_ACP_ENABLED": "1", + "VT_ACP_ZED_ENABLED": "1" + } + } + } + }, + "icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/vtcode.svg" + } + ], + "extensions": [] +} diff --git a/sources/CMakeLists.txt b/sources/CMakeLists.txt index 7bba6c0..0ae014b 100644 --- a/sources/CMakeLists.txt +++ b/sources/CMakeLists.txt @@ -4,5 +4,6 @@ add_subdirectory(logger) add_subdirectory(settings) add_subdirectory(context) add_subdirectory(session) +add_subdirectory(acp) add_subdirectory(UIControls) add_subdirectory(ChatView) diff --git a/sources/ChatView/AgentRoleController.cpp b/sources/ChatView/AgentRoleController.cpp deleted file mode 100644 index af21f53..0000000 --- a/sources/ChatView/AgentRoleController.cpp +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRoleController.hpp" - -#include - -#include "AgentRole.hpp" -#include "ChatAssistantSettings.hpp" -#include "GeneralSettings.hpp" - -namespace QodeAssist::Chat { - -AgentRoleController::AgentRoleController(QObject *parent) - : QObject(parent) -{ - connect( - &Settings::chatAssistantSettings().systemPrompt, - &Utils::BaseAspect::changed, - this, - &AgentRoleController::baseSystemPromptChanged); - - loadAvailableRoles(); -} - -QStringList AgentRoleController::availableRoles() const -{ - return m_availableRoles; -} - -QString AgentRoleController::currentRole() const -{ - return m_currentRole; -} - -QString AgentRoleController::baseSystemPrompt() const -{ - return Settings::chatAssistantSettings().systemPrompt(); -} - -QString AgentRoleController::currentRoleDescription() const -{ - const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); - if (lastRoleId.isEmpty()) - return Settings::AgentRolesManager::getNoRole().description; - - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (role.id.isEmpty()) - return Settings::AgentRolesManager::getNoRole().description; - - return role.description; -} - -QString AgentRoleController::currentRoleSystemPrompt() const -{ - const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); - if (lastRoleId.isEmpty()) - return QString(); - - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (role.id.isEmpty()) - return QString(); - - return role.systemPrompt; -} - -void AgentRoleController::loadAvailableRoles() -{ - const QList roles = Settings::AgentRolesManager::loadAllRoles(); - - m_availableRoles.clear(); - m_availableRoles.append(Settings::AgentRolesManager::getNoRole().name); - - for (const auto &role : roles) - m_availableRoles.append(role.name); - - const QString lastRoleId = Settings::chatAssistantSettings().lastUsedRoleId(); - m_currentRole = Settings::AgentRolesManager::getNoRole().name; - - if (!lastRoleId.isEmpty()) { - for (const auto &role : roles) { - if (role.id == lastRoleId) { - m_currentRole = role.name; - break; - } - } - } - - emit availableRolesChanged(); - emit currentRoleChanged(); -} - -void AgentRoleController::applyRole(const QString &roleName) -{ - auto &settings = Settings::chatAssistantSettings(); - - if (roleName == Settings::AgentRolesManager::getNoRole().name) { - settings.lastUsedRoleId.setValue(""); - settings.writeSettings(); - m_currentRole = roleName; - emit currentRoleChanged(); - return; - } - - const QList roles = Settings::AgentRolesManager::loadAllRoles(); - - for (const auto &role : roles) { - if (role.name == roleName) { - settings.lastUsedRoleId.setValue(role.id); - settings.writeSettings(); - m_currentRole = role.name; - emit currentRoleChanged(); - break; - } - } -} - -void AgentRoleController::openSettings() -{ - Settings::showSettings(Utils::Id("QodeAssist.AgentRoles")); -} - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/AgentRoleController.hpp b/sources/ChatView/AgentRoleController.hpp deleted file mode 100644 index 7eefe67..0000000 --- a/sources/ChatView/AgentRoleController.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include - -namespace QodeAssist::Chat { - -class AgentRoleController : public QObject -{ - Q_OBJECT - -public: - explicit AgentRoleController(QObject *parent = nullptr); - - QStringList availableRoles() const; - QString currentRole() const; - QString baseSystemPrompt() const; - QString currentRoleDescription() const; - QString currentRoleSystemPrompt() const; - - void loadAvailableRoles(); - void applyRole(const QString &roleName); - void openSettings(); - -signals: - void availableRolesChanged(); - void currentRoleChanged(); - void baseSystemPromptChanged(); - -private: - QStringList m_availableRoles; - QString m_currentRole; -}; - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatFileManager.cpp b/sources/ChatView/AttachmentStaging.cpp similarity index 86% rename from sources/ChatView/ChatFileManager.cpp rename to sources/ChatView/AttachmentStaging.cpp index 5c3bf7b..e81961d 100644 --- a/sources/ChatView/ChatFileManager.cpp +++ b/sources/ChatView/AttachmentStaging.cpp @@ -2,13 +2,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE -#include "ChatFileManager.hpp" +#include "AttachmentStaging.hpp" #include "Logger.hpp" #include #include #include -#include #include #include #include @@ -17,14 +16,14 @@ namespace QodeAssist::Chat { -ChatFileManager::ChatFileManager(QObject *parent) +AttachmentStaging::AttachmentStaging(QObject *parent) : QObject(parent) , m_intermediateStorageDir(getIntermediateStorageDir()) {} -ChatFileManager::~ChatFileManager() = default; +AttachmentStaging::~AttachmentStaging() = default; -QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths) +QStringList AttachmentStaging::processDroppedFiles(const QStringList &filePaths) { QStringList processedPaths; processedPaths.reserve(filePaths.size()); @@ -52,17 +51,17 @@ QStringList ChatFileManager::processDroppedFiles(const QStringList &filePaths) return processedPaths; } -void ChatFileManager::setChatFilePath(const QString &chatFilePath) +void AttachmentStaging::setChatFilePath(const QString &chatFilePath) { m_chatFilePath = chatFilePath; } -QString ChatFileManager::chatFilePath() const +QString AttachmentStaging::chatFilePath() const { return m_chatFilePath; } -void ChatFileManager::clearIntermediateStorage() +void AttachmentStaging::clearIntermediateStorage() { QDir dir(m_intermediateStorageDir); if (!dir.exists()) { @@ -82,13 +81,13 @@ void ChatFileManager::clearIntermediateStorage() } } -bool ChatFileManager::isFileAccessible(const QString &filePath) +bool AttachmentStaging::isFileAccessible(const QString &filePath) { QFileInfo fileInfo(filePath); return fileInfo.exists() && fileInfo.isFile() && fileInfo.isReadable(); } -void ChatFileManager::cleanupGlobalIntermediateStorage() +void AttachmentStaging::cleanupGlobalIntermediateStorage() { const QString basePath = Core::ICore::userResourcePath().toFSPathString(); const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files"); @@ -113,13 +112,13 @@ void ChatFileManager::cleanupGlobalIntermediateStorage() } if (removedCount > 0 || failedCount > 0) { - LOG_MESSAGE(QString("ChatFileManager global cleanup: removed=%1, failed=%2") + LOG_MESSAGE(QString("AttachmentStaging global cleanup: removed=%1, failed=%2") .arg(removedCount) .arg(failedCount)); } } -QString ChatFileManager::copyToIntermediateStorage(const QString &filePath) +QString AttachmentStaging::copyToIntermediateStorage(const QString &filePath) { QFileInfo fileInfo(filePath); if (!fileInfo.exists() || !fileInfo.isFile()) { @@ -154,7 +153,7 @@ QString ChatFileManager::copyToIntermediateStorage(const QString &filePath) return destinationPath; } -QString ChatFileManager::getIntermediateStorageDir() +QString AttachmentStaging::getIntermediateStorageDir() { const QString basePath = Core::ICore::userResourcePath().toFSPathString(); const QString intermediatePath = QDir(basePath).filePath("qodeassist/chat_temp_files"); @@ -168,7 +167,7 @@ QString ChatFileManager::getIntermediateStorageDir() return intermediatePath; } -QString ChatFileManager::generateIntermediateFileName(const QString &originalPath) +QString AttachmentStaging::generateIntermediateFileName(const QString &originalPath) { const QFileInfo fileInfo(originalPath); const QString extension = fileInfo.suffix(); diff --git a/sources/ChatView/ChatFileManager.hpp b/sources/ChatView/AttachmentStaging.hpp similarity index 88% rename from sources/ChatView/ChatFileManager.hpp rename to sources/ChatView/AttachmentStaging.hpp index 0f1ef41..5a372e5 100644 --- a/sources/ChatView/ChatFileManager.hpp +++ b/sources/ChatView/AttachmentStaging.hpp @@ -7,17 +7,16 @@ #include #include #include -#include namespace QodeAssist::Chat { -class ChatFileManager : public QObject +class AttachmentStaging : public QObject { Q_OBJECT public: - explicit ChatFileManager(QObject *parent = nullptr); - ~ChatFileManager(); + explicit AttachmentStaging(QObject *parent = nullptr); + ~AttachmentStaging(); QStringList processDroppedFiles(const QStringList &filePaths); void setChatFilePath(const QString &chatFilePath); diff --git a/sources/ChatView/CMakeLists.txt b/sources/ChatView/CMakeLists.txt index 2fae156..4294ca5 100644 --- a/sources/ChatView/CMakeLists.txt +++ b/sources/ChatView/CMakeLists.txt @@ -16,7 +16,10 @@ qt_add_qml_module(QodeAssistChatView qml/chatparts/TextBlock.qml qml/chatparts/ThinkingBlock.qml qml/chatparts/ToolBlock.qml + qml/chatparts/PermissionBlock.qml + qml/chatparts/PlanBlock.qml qml/chatparts/ChatItem.qml + qml/chatparts/BlockPayload.js qml/controls/AttachedFilesPlace.qml qml/controls/BottomBar.qml @@ -26,7 +29,7 @@ qt_add_qml_module(QodeAssistChatView qml/controls/SkillCommandPopup.qml qml/controls/Toast.qml qml/controls/TopBar.qml - qml/controls/SplitDropZone.qml + qml/controls/DropZone.qml qml/controls/MessageNavigator.qml RESOURCES @@ -34,8 +37,6 @@ qt_add_qml_module(QodeAssistChatView icons/attach-file-dark.svg icons/close-dark.svg icons/close-light.svg - icons/link-file-light.svg - icons/link-file-dark.svg icons/image-dark.svg icons/load-chat-dark.svg icons/save-chat-dark.svg @@ -67,22 +68,21 @@ qt_add_qml_module(QodeAssistChatView ChatModel.hpp ChatModel.cpp ChatRootView.hpp ChatRootView.cpp ChatController.hpp ChatController.cpp + ConversationPorts.hpp + ConversationCoordinator.hpp ConversationCoordinator.cpp LlmChatBackend.hpp LlmChatBackend.cpp MessagePart.hpp ChatUtils.h ChatUtils.cpp - ChatSerializer.hpp ChatSerializer.cpp - ChatHistoryBridge.hpp ChatHistoryBridge.cpp + ChatFileStore.hpp ChatFileStore.cpp TurnContextAdapters.hpp TurnContextAdapters.cpp ChatView.hpp ChatView.cpp ChatData.hpp FileItem.hpp FileItem.cpp - ChatFileManager.hpp ChatFileManager.cpp + AttachmentStaging.hpp AttachmentStaging.cpp ChatCompressor.hpp ChatCompressor.cpp - AgentRoleController.hpp AgentRoleController.cpp ChatConfigurationController.hpp ChatConfigurationController.cpp FileEditController.hpp FileEditController.cpp InputTokenCounter.hpp InputTokenCounter.cpp - ChatHistoryStore.hpp ChatHistoryStore.cpp FileMentionItem.hpp FileMentionItem.cpp SessionFileRegistry.hpp SessionFileRegistry.cpp ) @@ -98,6 +98,7 @@ target_link_libraries(QodeAssistChatView QodeAssistSettings Context QodeAssistSession + QodeAssistAcp QodeAssistUIControlsplugin QodeAssistLogger LLMQore diff --git a/sources/ChatView/ChatCompressor.cpp b/sources/ChatView/ChatCompressor.cpp index 666da2f..e5922f4 100644 --- a/sources/ChatView/ChatCompressor.cpp +++ b/sources/ChatView/ChatCompressor.cpp @@ -25,15 +25,48 @@ ChatCompressor::ChatCompressor(QObject *parent) : QObject(parent) {} +QString ChatCompressor::configurationIssue() +{ + auto &settings = Settings::generalSettings(); + + if (settings.caProvider().isEmpty()) + return tr("no provider is assigned to the chat feature"); + if (settings.caModel().isEmpty()) + return tr("no model is assigned to the chat feature"); + if (settings.caTemplate().isEmpty()) + return tr("no prompt template is assigned to the chat feature"); + if (settings.caUrl().isEmpty()) + return tr("the chat feature has no URL configured"); + + if (!Providers::ProvidersManager::instance().getProviderByName(settings.caProvider())) + return tr("the provider \"%1\" is not available").arg(settings.caProvider()); + + if (!Templates::PromptTemplateManager::instance().getChatTemplateByName(settings.caTemplate())) + return tr("the prompt template \"%1\" is not available").arg(settings.caTemplate()); + + return {}; +} + +void ChatCompressor::startSummary(const Session::ConversationHistory &history) +{ + beginCompression(QString(), history, /*summaryOnly*/ true); +} + void ChatCompressor::startCompression( const QString &chatFilePath, const Session::ConversationHistory &history) +{ + beginCompression(chatFilePath, history, /*summaryOnly*/ false); +} + +void ChatCompressor::beginCompression( + const QString &chatFilePath, const Session::ConversationHistory &history, bool summaryOnly) { if (m_isCompressing) { emit compressionFailed(tr("Compression already in progress")); return; } - if (chatFilePath.isEmpty()) { + if (!summaryOnly && chatFilePath.isEmpty()) { emit compressionFailed(tr("No chat file to compress")); return; } @@ -62,11 +95,13 @@ void ChatCompressor::startCompression( } m_isCompressing = true; + m_summaryOnly = summaryOnly; m_rows = rows; m_originalChatPath = chatFilePath; m_accumulatedSummary.clear(); emit compressionStarted(); + emit compressingChanged(); connectProviderSignals(); @@ -122,6 +157,19 @@ void ChatCompressor::onFullResponseReceived(const QString &requestId, const QStr LOG_MESSAGE( QString("Received summary, length: %1 characters").arg(m_accumulatedSummary.length())); + if (m_summaryOnly) { + const QString summary = m_accumulatedSummary.trimmed(); + cleanupState(); + + if (summary.isEmpty()) { + emit compressionFailed(tr("The summary came back empty")); + return; + } + + emit summaryReady(summary); + return; + } + QString compressedPath = createCompressedChatPath(m_originalChatPath); if (!createCompressedChatFile(m_originalChatPath, compressedPath, m_accumulatedSummary)) { handleCompressionError(tr("Failed to save compressed chat")); @@ -181,12 +229,13 @@ void ChatCompressor::buildRequestPayload( QVector messages; for (const Session::MessageRow &row : std::as_const(m_rows)) { - if (row.kind == Session::RowKind::Tool || row.kind == Session::RowKind::FileEdit - || row.kind == Session::RowKind::Thinking) + const Session::RowTreatment treatment + = Session::rowTreatmentFor(Session::RowAudience::Compression, row.kind); + if (treatment == Session::RowTreatment::Omit) continue; LLMCore::Message apiMessage; - apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant"; + apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant"; apiMessage.content = row.content; messages.append(apiMessage); } @@ -282,12 +331,18 @@ void ChatCompressor::cleanupState() { disconnectAllSignals(); + const bool wasCompressing = m_isCompressing; + m_isCompressing = false; + m_summaryOnly = false; m_currentRequestId.clear(); m_originalChatPath.clear(); m_accumulatedSummary.clear(); m_rows.clear(); m_provider = nullptr; + + if (wasCompressing) + emit compressingChanged(); } } // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatCompressor.hpp b/sources/ChatView/ChatCompressor.hpp index 8e10092..d9aba11 100644 --- a/sources/ChatView/ChatCompressor.hpp +++ b/sources/ChatView/ChatCompressor.hpp @@ -30,13 +30,22 @@ public: void startCompression( const QString &chatFilePath, const Session::ConversationHistory &history); + void startSummary(const Session::ConversationHistory &history); + +signals: + void compressingChanged(); + +public: bool isCompressing() const; void cancelCompression(); + static QString configurationIssue(); + signals: void compressionStarted(); void compressionCompleted(const QString &compressedChatPath); + void summaryReady(const QString &summary); void compressionFailed(const QString &error); private slots: @@ -54,8 +63,13 @@ private: void cleanupState(); void handleCompressionError(const QString &error); void buildRequestPayload(QJsonObject &payload, Templates::PromptTemplate *promptTemplate); + void beginCompression( + const QString &chatFilePath, + const Session::ConversationHistory &history, + bool summaryOnly); bool m_isCompressing = false; + bool m_summaryOnly = false; QString m_currentRequestId; QString m_originalChatPath; QString m_accumulatedSummary; diff --git a/sources/ChatView/ChatConfigurationController.cpp b/sources/ChatView/ChatConfigurationController.cpp index fd4673c..35c2cad 100644 --- a/sources/ChatView/ChatConfigurationController.cpp +++ b/sources/ChatView/ChatConfigurationController.cpp @@ -8,9 +8,19 @@ #include "ConfigurationManager.hpp" #include "GeneralSettings.hpp" +#include "acp/AgentCatalogStore.hpp" namespace QodeAssist::Chat { +namespace { + +QString agentEntry(const QString &agentName) +{ + return ChatConfigurationController::tr("Agent: %1").arg(agentName); +} + +} // namespace + ChatConfigurationController::ChatConfigurationController(QObject *parent) : QObject(parent) { @@ -29,6 +39,26 @@ ChatConfigurationController::ChatConfigurationController(QObject *parent) loadAvailableConfigurations(); } +void ChatConfigurationController::setAgentCatalog(Acp::AgentCatalogStore *store) +{ + if (m_agents == store) + return; + + if (m_agents) + disconnect(m_agents, nullptr, this, nullptr); + + m_agents = store; + if (m_agents) { + connect( + m_agents, + &Acp::AgentCatalogStore::catalogChanged, + this, + &ChatConfigurationController::loadAvailableConfigurations); + } + + loadAvailableConfigurations(); +} + QStringList ChatConfigurationController::availableConfigurations() const { return m_availableConfigurations; @@ -41,12 +71,33 @@ QString ChatConfigurationController::currentConfiguration() const void ChatConfigurationController::updateCurrentConfiguration() { + if (!m_boundAgentName.isEmpty()) { + m_currentConfiguration = agentEntry(m_boundAgentName); + emit currentConfigurationChanged(); + return; + } + auto &settings = Settings::generalSettings(); m_currentConfiguration = QString("%1 - %2").arg(settings.caProvider.value(), settings.caModel.value()); emit currentConfigurationChanged(); } +void ChatConfigurationController::setBoundAgent(const Acp::AgentDefinition &agent) +{ + m_boundAgentName = agent.name; + updateCurrentConfiguration(); +} + +void ChatConfigurationController::clearBoundAgent() +{ + if (m_boundAgentName.isEmpty()) + return; + + m_boundAgentName.clear(); + updateCurrentConfiguration(); +} + void ChatConfigurationController::loadAvailableConfigurations() { auto &manager = Settings::ConfigurationManager::instance(); @@ -56,23 +107,56 @@ void ChatConfigurationController::loadAvailableConfigurations() Settings::ConfigurationType::Chat); m_availableConfigurations.clear(); + m_agentIdByEntry.clear(); m_availableConfigurations.append(QObject::tr("Current Settings")); for (const Settings::AIConfiguration &config : configs) { m_availableConfigurations.append(config.name); } + if (m_agents) { + for (const Acp::AgentDefinition &agent : m_agents->catalog().launchableAgents()) { + const QString entry = agentEntry(agent.name); + m_agentIdByEntry.insert(entry, agent.id); + m_availableConfigurations.append(entry); + } + } + updateCurrentConfiguration(); emit availableConfigurationsChanged(); } +std::optional ChatConfigurationController::agentById(const QString &agentId) +{ + if (agentId.isEmpty() || !m_agents) + return std::nullopt; + + if (auto agent = m_agents->catalog().agent(agentId)) + return agent; + + m_agents->reload(); + return m_agents->catalog().agent(agentId); +} + void ChatConfigurationController::applyConfiguration(const QString &configName) { - if (configName == QObject::tr("Current Settings")) { + const QString agentId = m_agentIdByEntry.value(configName); + if (!agentId.isEmpty()) { + if (!m_agents) + return; + if (const auto agent = m_agents->catalog().agent(agentId)) + emit agentRequested(*agent); return; } + if (configName == QObject::tr("Current Settings")) { + emit llmRequested(); + return; + } + + emit llmRequested(); + auto &manager = Settings::ConfigurationManager::instance(); QVector configs = manager.configurations( Settings::ConfigurationType::Chat); diff --git a/sources/ChatView/ChatConfigurationController.hpp b/sources/ChatView/ChatConfigurationController.hpp index bb4ec1e..fd35c24 100644 --- a/sources/ChatView/ChatConfigurationController.hpp +++ b/sources/ChatView/ChatConfigurationController.hpp @@ -4,9 +4,18 @@ #pragma once +#include + +#include #include #include +#include "acp/AgentDefinition.hpp" + +namespace QodeAssist::Acp { +class AgentCatalogStore; +} + namespace QodeAssist::Chat { class ChatConfigurationController : public QObject @@ -19,18 +28,30 @@ public: QStringList availableConfigurations() const; QString currentConfiguration() const; + void setAgentCatalog(Acp::AgentCatalogStore *store); + void loadAvailableConfigurations(); void applyConfiguration(const QString &configName); + void setBoundAgent(const Acp::AgentDefinition &agent); + void clearBoundAgent(); + + std::optional agentById(const QString &agentId); + signals: void availableConfigurationsChanged(); void currentConfigurationChanged(); + void agentRequested(const QodeAssist::Acp::AgentDefinition &agent); + void llmRequested(); private: void updateCurrentConfiguration(); + Acp::AgentCatalogStore *m_agents = nullptr; QStringList m_availableConfigurations; + QHash m_agentIdByEntry; QString m_currentConfiguration; + QString m_boundAgentName; }; } // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatController.cpp b/sources/ChatView/ChatController.cpp index cdbb304..ba0f590 100644 --- a/sources/ChatView/ChatController.cpp +++ b/sources/ChatView/ChatController.cpp @@ -8,15 +8,19 @@ #include #include -#include "ChatHistoryBridge.hpp" -#include "ChatSerializer.hpp" +#include +#include +#include + +#include "ChatFileStore.hpp" #include "LlmChatBackend.hpp" #include "TurnContextAdapters.hpp" -#include "context/ChangesManager.h" -#include "context/RulesLoader.hpp" +#include "context/FileEditManager.hpp" #include "logger/Logger.hpp" #include "session/FileEditPayload.hpp" #include "session/TurnContextBuilder.hpp" +#include "acp/AcpChatBackend.hpp" +#include "mcp/AgentKnowledgeServer.hpp" #include "settings/ChatAssistantSettings.hpp" namespace QodeAssist::Chat { @@ -28,9 +32,42 @@ ChatController::ChatController( , m_chatModel(chatModel) , m_contextManager(new Context::ContextManager(this)) , m_session(new Session::Session(this)) - , m_backend(new LlmChatBackend(promptProvider, this)) + , m_llmBackend(new LlmChatBackend(promptProvider, this)) + , m_acpBackend(new Acp::AcpChatBackend(this)) + , m_agentKnowledge(new Mcp::AgentKnowledgeServer(this)) + , m_backend(m_llmBackend) { - new ChatHistoryBridge(m_session, chatModel, this); + connect(m_session, &Session::Session::rowsReset, m_chatModel, &ChatModel::resetMessages); + connect(m_session, &Session::Session::rowsAppended, m_chatModel, &ChatModel::appendMessages); + connect(m_session, &Session::Session::rowUpdated, m_chatModel, &ChatModel::updateMessage); + connect(m_session, &Session::Session::rowsRemoved, m_chatModel, &ChatModel::removeMessages); + m_chatModel->resetMessages(m_session->rows()); + + m_acpBackend->setStoredContentLoader(&ChatFileStore::loadRawContentFromStorage); + m_agentKnowledge->setIgnorePredicate([this](const QString &filePath) { + auto *project = ProjectExplorer::ProjectManager::projectForFile( + Utils::FilePath::fromString(filePath)); + return m_contextManager->ignoreManager()->shouldIgnore(filePath, project); + }); + m_acpBackend->setKnowledgeService(m_agentKnowledge); + + connect( + m_session, + &Session::Session::sessionInfoReceived, + this, + &ChatController::sessionInfoReceived); + + connect( + m_acpBackend, + &Acp::AcpChatBackend::agentSessionUnavailable, + this, + &ChatController::agentSessionUnavailable); + + connect( + m_acpBackend, + &Acp::AcpChatBackend::availableCommandsChanged, + this, + &ChatController::agentCommandsChanged); m_session->setBackend(m_backend); @@ -39,7 +76,7 @@ ChatController::ChatController( connect(m_session, &Session::Session::turnFinished, this, [this](const QString &turnId) { QString applyError; - if (!Context::ChangesManager::instance().applyPendingEditsForRequest(turnId, &applyError)) { + if (!Context::FileEditManager::instance().applyPendingEditsForRequest(turnId, &applyError)) { LOG_MESSAGE(QString("Some edits for request %1 were not auto-applied: %2") .arg(turnId, applyError)); } @@ -56,14 +93,53 @@ ChatController::ChatController( connect(m_session, &Session::Session::rowsReset, this, [this] { registerHistoricalEdits(); }); - auto &changes = Context::ChangesManager::instance(); - connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &id) { + connect( + m_session, + &Session::Session::agentFileEditRecorded, + this, + [](const QString &turnId, + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent) { + const QFileInfo info(filePath); + const QString canonical = info.canonicalFilePath(); + const Utils::FilePath target = Utils::FilePath::fromString( + canonical.isEmpty() ? info.absoluteFilePath() : canonical); + + bool insideProject = false; + const QList projects + = ProjectExplorer::ProjectManager::projects(); + for (const ProjectExplorer::Project *project : projects) { + if (target.isChildOf(project->projectDirectory())) { + insideProject = true; + break; + } + } + + if (!insideProject) { + LOG_MESSAGE( + QString("Agent edit %1 targets %2 outside every open project; recorded " + "in the transcript only, without apply/undo actions") + .arg(editId, filePath)); + return; + } + + Context::FileEditManager::instance().registerAppliedFileEdit( + editId, target.toUrlishString(), oldContent, newContent, turnId); + }); + + auto &changes = Context::FileEditManager::instance(); + connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &id) { m_session->updateFileEditStatus(id, "applied", "Successfully applied"); }); - connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &id) { + connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &id) { m_session->updateFileEditStatus(id, "rejected", "Rejected by user"); }); - connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &id) { + connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &id) { + recordFileEditStatus(id, "rejected", "Successfully undone"); + }); + connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &id) { m_session->updateFileEditStatus(id, "archived", "Archived (from previous conversation turn)"); }); } @@ -73,32 +149,107 @@ void ChatController::setSkillsManager(Skills::SkillsManager *skillsManager) m_skillsManager = skillsManager; } +void ChatController::bindAgent(const Acp::AgentDefinition &agent) +{ + m_acpBackend->bindAgent(agent); + activateBackend(m_acpBackend); +} + +void ChatController::bindLlm() +{ + activateBackend(m_llmBackend); +} + +QString ChatController::boundAgentId() const +{ + return m_backend == m_acpBackend ? m_acpBackend->boundAgentId() : QString(); +} + +QString ChatController::boundAgentName() const +{ + return m_backend == m_acpBackend ? m_acpBackend->boundAgentName() : QString(); +} + +QList ChatController::agentCommands() const +{ + if (m_backend != m_acpBackend) + return {}; + return m_acpBackend->availableCommands(); +} + +bool ChatController::transcriptEmpty() const +{ + return m_session->rows().isEmpty(); +} + +Acp::AgentBinding ChatController::agentBinding() const +{ + if (m_backend != m_acpBackend) + return {}; + + return Acp::AgentBinding{m_acpBackend->boundAgentId(), m_acpBackend->bindingSessionId()}; +} + +void ChatController::resumeAgentSession(const QString &sessionId) +{ + m_acpBackend->resumeSession(sessionId); +} + +void ChatController::startFreshAgentSession() +{ + m_acpBackend->startFreshSession(); +} + +void ChatController::startFreshAgentSession(const QString &handoverSummary) +{ + m_acpBackend->clearToolSession(m_chatFilePath); + m_acpBackend->startFreshSession(); + m_acpBackend->setHandoverSummary(handoverSummary); +} + +void ChatController::releaseAgentSession() +{ + m_acpBackend->clearToolSession(m_chatFilePath); +} + +bool ChatController::conversationStarted() const +{ + return !m_session->history().messages().isEmpty(); +} + +void ChatController::activateBackend(Session::ChatBackend *backend) +{ + if (m_backend == backend) + return; + + m_session->cancel(); + + if (m_backend) + m_backend->clearToolSession(m_chatFilePath); + + m_backend = backend; + m_session->setBackend(backend); + backend->setChatFilePath(m_chatFilePath); +} + Session::Session *ChatController::session() const { return m_session; } -void ChatController::sendMessage( - const QString &message, - const QList &attachments, - const QList &linkedFiles, - bool useTools, - bool useThinking) +void ChatController::sendMessage(const QString &message, const QList &attachments) { if (message.trimmed().isEmpty() && attachments.isEmpty()) { LOG_MESSAGE("Ignoring empty chat message"); return; } - Context::ChangesManager::instance().archiveAllNonArchivedEdits(); + Context::FileEditManager::instance().archiveAllNonArchivedEdits(); - m_session->sendTurn( - composeUserBlocks(message, attachments), - buildTurnContext(message, linkedFiles), - Session::TurnOptions{useTools, useThinking}); + m_session->sendTurn(composeUserBlocks(message, attachments), buildTurnContext(message)); } -void ChatController::clearMessages() +void ChatController::clearConversation() { m_backend->clearToolSession(m_chatFilePath); m_session->clear(); @@ -114,6 +265,11 @@ void ChatController::resetToRow(int rowIndex) m_session->truncateRows(rowIndex); } +void ChatController::respondToPermission(const QString &requestId, const QString &optionId) +{ + m_session->respondPermission(requestId, optionId); +} + QList ChatController::composeUserBlocks( const QString &message, const QList &attachments) { @@ -132,7 +288,7 @@ QList ChatController::composeUserBlocks( if (!textFiles.isEmpty() && !m_chatFilePath.isEmpty()) { for (const auto &file : m_contextManager->getContentFiles(textFiles)) { QString storedPath; - if (!ChatSerializer::saveContentToStorage( + if (!ChatFileStore::saveContentToStorage( m_chatFilePath, file.filename, file.content.toUtf8().toBase64(), storedPath)) { continue; } @@ -152,7 +308,7 @@ QList ChatController::composeUserBlocks( const QFileInfo fileInfo(imagePath); QString storedPath; - if (!ChatSerializer::saveContentToStorage( + if (!ChatFileStore::saveContentToStorage( m_chatFilePath, fileInfo.fileName(), base64Data, storedPath)) { continue; } @@ -169,32 +325,23 @@ QList ChatController::composeUserBlocks( return blocks; } -std::optional ChatController::buildTurnContext( - const QString &message, const QList &linkedFiles) const +Session::TurnContext ChatController::buildTurnContext(const QString &message) const { auto &chatAssistantSettings = Settings::chatAssistantSettings(); - if (!chatAssistantSettings.useSystemPrompt()) - return std::nullopt; Session::TurnContextRequest contextRequest; contextRequest.message = message; - contextRequest.basePrompt = chatAssistantSettings.systemPrompt(); - contextRequest.linkedFilePaths = linkedFiles; + contextRequest.needs = m_backend->contextNeeds(); - const QString lastRoleId = chatAssistantSettings.lastUsedRoleId(); - if (!lastRoleId.isEmpty()) { - const Settings::AgentRole role = Settings::AgentRolesManager::loadRole(lastRoleId); - if (!role.id.isEmpty()) - contextRequest.rolePrompt = role.systemPrompt; - } + if (contextRequest.needs.systemPrompt) + contextRequest.basePrompt = chatAssistantSettings.systemPrompt(); - auto *project = Context::RulesLoader::getActiveProject(); + auto *project = activeProject(); ProjectContextQtCreator projectPort(project); - LinkedFilesQtCreator linkedFilesPort(m_contextManager); auto skillsPort = makeSkillsContext(m_skillsManager, project); - const Session::TurnContextBuilder builder(projectPort, skillsPort.get(), linkedFilesPort); + const Session::TurnContextBuilder builder(projectPort, skillsPort.get()); return builder.build(contextRequest); } @@ -202,7 +349,7 @@ std::optional ChatController::buildTurnContext( void ChatController::recordFileEditStatus( const QString &editId, const QString &status, const QString &fallbackMessage) { - const auto edit = Context::ChangesManager::instance().getFileEdit(editId); + const auto edit = Context::FileEditManager::instance().getFileEdit(editId); const QString message = edit.statusMessage.isEmpty() ? fallbackMessage : edit.statusMessage; m_session->updateFileEditStatus(editId, status, message); } @@ -232,7 +379,7 @@ void ChatController::registerHistoricalEdits() continue; } - Context::ChangesManager::instance().addFileEdit( + Context::FileEditManager::instance().addFileEdit( editId, filePath, payload->value("old_content").toString(), diff --git a/sources/ChatView/ChatController.hpp b/sources/ChatView/ChatController.hpp index 10a1d0c..9da3a61 100644 --- a/sources/ChatView/ChatController.hpp +++ b/sources/ChatView/ChatController.hpp @@ -7,7 +7,12 @@ #include #include +#include + #include "ChatModel.hpp" +#include "ConversationPorts.hpp" +#include "acp/AgentBinding.hpp" +#include "acp/AgentDefinition.hpp" #include "session/Session.hpp" #include "templates/IPromptProvider.hpp" #include @@ -16,12 +21,19 @@ namespace QodeAssist::Skills { class SkillsManager; } +namespace QodeAssist::Acp { +class AcpChatBackend; +} + +namespace QodeAssist::Mcp { +class AgentKnowledgeServer; +} + namespace QodeAssist::Chat { -class ChatHistoryBridge; class LlmChatBackend; -class ChatController : public QObject +class ChatController : public QObject, public IConversationPort { Q_OBJECT @@ -31,15 +43,10 @@ public: void setSkillsManager(Skills::SkillsManager *skillsManager); - void sendMessage( - const QString &message, - const QList &attachments = {}, - const QList &linkedFiles = {}, - bool useTools = false, - bool useThinking = false); - void clearMessages(); + void sendMessage(const QString &message, const QList &attachments = {}); void cancelRequest(); void resetToRow(int rowIndex); + void respondToPermission(const QString &requestId, const QString &optionId); Session::Session *session() const; Context::ContextManager *contextManager() const; @@ -47,7 +54,26 @@ public: void setChatFilePath(const QString &filePath); QString chatFilePath() const; + QString boundAgentId() const override; + QString boundAgentName() const; + QList agentCommands() const; + bool conversationStarted() const override; + bool transcriptEmpty() const override; + Acp::AgentBinding agentBinding() const override; + + void bindAgent(const Acp::AgentDefinition &agent) override; + void bindLlm() override; + void clearConversation() override; + + void resumeAgentSession(const QString &sessionId) override; + void startFreshAgentSession() override; + void startFreshAgentSession(const QString &handoverSummary) override; + void releaseAgentSession() override; + signals: + void sessionInfoReceived(const QString &title); + void agentCommandsChanged(); + void agentSessionUnavailable(const QString &reason); void errorOccurred(const QString &error); void messageReceivedCompletely(); void requestStarted(const QString &requestId); @@ -57,11 +83,11 @@ signals: private: QList composeUserBlocks( const QString &message, const QList &attachments); - std::optional buildTurnContext( - const QString &message, const QList &linkedFiles) const; + Session::TurnContext buildTurnContext(const QString &message) const; void recordFileEditStatus( const QString &editId, const QString &status, const QString &fallbackMessage); void registerHistoricalEdits(); + void activateBackend(Session::ChatBackend *backend); bool isImageFile(const QString &filePath) const; QString getMediaTypeForImage(const QString &filePath) const; @@ -72,7 +98,10 @@ private: Context::ContextManager *m_contextManager = nullptr; Skills::SkillsManager *m_skillsManager = nullptr; Session::Session *m_session = nullptr; - LlmChatBackend *m_backend = nullptr; + LlmChatBackend *m_llmBackend = nullptr; + Acp::AcpChatBackend *m_acpBackend = nullptr; + Mcp::AgentKnowledgeServer *m_agentKnowledge = nullptr; + Session::ChatBackend *m_backend = nullptr; QString m_chatFilePath; }; diff --git a/sources/ChatView/ChatFileStore.cpp b/sources/ChatView/ChatFileStore.cpp new file mode 100644 index 0000000..0072561 --- /dev/null +++ b/sources/ChatView/ChatFileStore.cpp @@ -0,0 +1,429 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ChatFileStore.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "Logger.hpp" +#include "ProjectSettings.hpp" +#include "session/HistorySerializer.hpp" +#include "session/Session.hpp" + +namespace QodeAssist::Chat { + +ChatFileStore::ChatFileStore(Session::Session *session, QObject *parent) + : QObject(parent) + , m_session(session) +{} + +QString ChatFileStore::historyDir() const +{ + QString path; + + if (auto project = ProjectExplorer::ProjectManager::startupProject()) { + Settings::ProjectSettings projectSettings(project); + path = projectSettings.chatHistoryPath().toFSPathString(); + } else { + QDir baseDir(Core::ICore::userResourcePath().toFSPathString()); + path = baseDir.filePath("qodeassist/chat_history"); + } + + QDir dir(path); + if (!dir.exists() && !dir.mkpath(".")) { + LOG_MESSAGE(QString("Failed to create directory: %1").arg(path)); + return QString(); + } + + return path; +} + +QString ChatFileStore::suggestedFileName() const +{ + QString shortMessage; + + if (!m_session) + return generateChatFileName(shortMessage, historyDir()); + + const QList &rows = m_session->rows(); + if (!rows.isEmpty()) { + shortMessage = rows.first().content.split('\n').first().simplified().left(30); + + if (shortMessage.isEmpty() && !rows.first().images.isEmpty()) + shortMessage = "image_chat"; + } + + return generateChatFileName(shortMessage, historyDir()); +} + +QString ChatFileStore::autosaveFilePath(const QString &recentFilePath) const +{ + if (!recentFilePath.isEmpty()) { + return recentFilePath; + } + + QString dir = historyDir(); + if (dir.isEmpty()) { + return QString(); + } + + return QDir(dir).filePath(suggestedFileName() + ".json"); +} + +QString ChatFileStore::autosaveFilePath( + const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const +{ + if (!recentFilePath.isEmpty()) { + return recentFilePath; + } + + QString dir = historyDir(); + if (dir.isEmpty()) { + return QString(); + } + + QString shortMessage = firstMessage.split('\n').first().simplified().left(30); + + if (shortMessage.isEmpty() && hasImageAttachments) { + shortMessage = "image_chat"; + } + + QString fileName = generateChatFileName(shortMessage, dir); + return QDir(dir).filePath(fileName + ".json"); +} + +void ChatFileStore::setBindingReader(BindingReader reader) +{ + m_bindingReader = std::move(reader); +} + +void ChatFileStore::setBindingWriter(BindingWriter writer) +{ + m_bindingWriter = std::move(writer); +} + +SerializationResult ChatFileStore::save(const QString &filePath) const +{ + if (!m_session) + return {false, QString("Chat session is no longer available")}; + + const Acp::AgentBinding binding = m_bindingReader ? m_bindingReader() : Acp::AgentBinding{}; + return saveToFile(m_session->history(), binding, filePath); +} + +SerializationResult ChatFileStore::load(const QString &filePath) const +{ + if (!m_session) + return {false, QString("Chat session is no longer available")}; + + Session::ConversationHistory history; + Acp::AgentBinding binding; + const SerializationResult result = loadFromFile(history, binding, filePath); + if (!result.success) + return result; + + m_session->setHistory(history); + if (m_bindingWriter) + m_bindingWriter(binding); + + return result; +} + +void ChatFileStore::showSaveDialog() +{ + QString initialDir = historyDir(); + + QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History")); + dialog->setAcceptMode(QFileDialog::AcceptSave); + dialog->setFileMode(QFileDialog::AnyFile); + dialog->setNameFilter(tr("JSON files (*.json)")); + dialog->setDefaultSuffix("json"); + if (!initialDir.isEmpty()) { + dialog->setDirectory(initialDir); + dialog->selectFile(suggestedFileName() + ".json"); + } + + connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) { + if (result == QFileDialog::Accepted) { + QStringList files = dialog->selectedFiles(); + if (!files.isEmpty()) { + emit saveRequested(files.first()); + } + } + dialog->deleteLater(); + }); + + dialog->open(); +} + +void ChatFileStore::showLoadDialog() +{ + QString initialDir = historyDir(); + + QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History")); + dialog->setAcceptMode(QFileDialog::AcceptOpen); + dialog->setFileMode(QFileDialog::ExistingFile); + dialog->setNameFilter(tr("JSON files (*.json)")); + if (!initialDir.isEmpty()) { + dialog->setDirectory(initialDir); + } + + connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) { + if (result == QFileDialog::Accepted) { + QStringList files = dialog->selectedFiles(); + if (!files.isEmpty()) { + emit loadRequested(files.first()); + } + } + dialog->deleteLater(); + }); + + dialog->open(); +} + +void ChatFileStore::openHistoryFolder() const +{ + QString path; + if (auto project = ProjectExplorer::ProjectManager::startupProject()) { + Settings::ProjectSettings projectSettings(project); + path = projectSettings.chatHistoryPath().toFSPathString(); + } else { + QDir baseDir(Core::ICore::userResourcePath().toFSPathString()); + path = baseDir.filePath("qodeassist/chat_history"); + } + + QDir dir(path); + if (!dir.exists()) { + dir.mkpath("."); + } + + QUrl url = QUrl::fromLocalFile(dir.absolutePath()); + QDesktopServices::openUrl(url); +} + +QString ChatFileStore::generateChatFileName(const QString &shortMessage, const QString &dir) const +{ + static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]"); + static const QRegularExpression underSymbols = QRegularExpression("_+"); + + QStringList parts; + QString sanitizedMessage = shortMessage; + sanitizedMessage.replace(saitizeSymbols, "_"); + sanitizedMessage.replace(underSymbols, "_"); + sanitizedMessage = sanitizedMessage.trimmed(); + + if (!sanitizedMessage.isEmpty()) { + if (sanitizedMessage.startsWith('_')) { + sanitizedMessage.remove(0, 1); + } + if (sanitizedMessage.endsWith('_')) { + sanitizedMessage.chop(1); + } + + QString fullPath = QDir(dir).filePath(sanitizedMessage); + QFileInfo fileInfo(fullPath); + if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) { + parts << sanitizedMessage; + } + } + + parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"); + + QString fileName = parts.join("_"); + QString fullPath = QDir(dir).filePath(fileName); + QFileInfo finalCheck(fullPath); + + if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) { + fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm")); + } + + return fileName; +} + +SerializationResult ChatFileStore::saveToFile( + const Session::ConversationHistory &history, + const Acp::AgentBinding &binding, + const QString &filePath) +{ + if (!ensureDirectoryExists(filePath)) { + return {false, "Failed to create directory structure"}; + } + + QJsonObject root = Session::HistorySerializer::toJson(history); + if (!binding.isEmpty()) + root["agent"] = binding.toJson(); + + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly)) { + return {false, QString("Failed to open file for writing: %1").arg(filePath)}; + } + + if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) { + return {false, QString("Failed to write to file: %1").arg(file.errorString())}; + } + + if (!file.commit()) { + return {false, QString("Failed to save file: %1").arg(file.errorString())}; + } + + return {true, QString()}; +} + +SerializationResult ChatFileStore::loadFromFile( + Session::ConversationHistory &history, Acp::AgentBinding &binding, const QString &filePath) +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + return {false, QString("Failed to open file for reading: %1").arg(filePath)}; + } + + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError) { + return {false, QString("JSON parse error: %1").arg(error.errorString())}; + } + + const QJsonObject root = doc.object(); + const QString version = root["version"].toString(); + + if (!Session::HistorySerializer::isSupportedVersion(version)) { + return {false, QString("Unsupported version: %1").arg(version)}; + } + + int droppedBlocks = 0; + const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks); + if (!loaded) { + return {false, QString("Failed to read chat history from: %1").arg(filePath)}; + } + + if (version != Session::HistorySerializer::currentVersion()) { + LOG_MESSAGE(QString("Converted chat from format %1 to %2") + .arg(version, Session::HistorySerializer::currentVersion())); + } + + history = *loaded; + + QString bindingError; + binding = Acp::AgentBinding::fromJson(root["agent"], &bindingError); + if (!bindingError.isEmpty()) { + const QString warning + = QString("This chat records which agent held it, but %1, so it opens unbound") + .arg(bindingError); + LOG_MESSAGE(QString("%1: %2").arg(filePath, warning)); + return {true, QString(), warning}; + } + + if (droppedBlocks > 0) { + const QString warning + = QString( + "%1 message part(s) in this chat could not be read and will be lost if " + "the chat is saved again") + .arg(droppedBlocks); + LOG_MESSAGE(QString("%1: %2").arg(filePath, warning)); + return {true, QString(), warning}; + } + + return {true, QString(), QString()}; +} + +bool ChatFileStore::ensureDirectoryExists(const QString &filePath) +{ + QFileInfo fileInfo(filePath); + QDir dir = fileInfo.dir(); + return dir.exists() || dir.mkpath("."); +} + +QString ChatFileStore::getChatContentFolder(const QString &chatFilePath) +{ + QFileInfo fileInfo(chatFilePath); + QString baseName = fileInfo.completeBaseName(); + QString dirPath = fileInfo.absolutePath(); + return QDir(dirPath).filePath(baseName + "_content"); +} + +bool ChatFileStore::saveContentToStorage( + const QString &chatFilePath, + const QString &fileName, + const QString &base64Data, + QString &storedPath) +{ + QString contentFolder = getChatContentFolder(chatFilePath); + QDir dir; + if (!dir.exists(contentFolder)) { + if (!dir.mkpath(contentFolder)) { + LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder)); + return false; + } + } + + QFileInfo originalFileInfo(fileName); + QString extension = originalFileInfo.suffix(); + QString baseName = originalFileInfo.completeBaseName(); + QString uniqueName = QString("%1_%2.%3") + .arg(baseName) + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8)) + .arg(extension); + + QString fullPath = QDir(contentFolder).filePath(uniqueName); + + QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8()); + QFile file(fullPath); + if (!file.open(QIODevice::WriteOnly)) { + LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath)); + return false; + } + + if (file.write(contentData) == -1) { + LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString())); + return false; + } + + file.close(); + + storedPath = uniqueName; + LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath)); + + return true; +} + +QByteArray ChatFileStore::loadRawContentFromStorage( + const QString &chatFilePath, const QString &storedPath) +{ + QString contentFolder = getChatContentFolder(chatFilePath); + QString fullPath = QDir(contentFolder).filePath(storedPath); + + QFile file(fullPath); + if (!file.open(QIODevice::ReadOnly)) { + LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath)); + return QByteArray(); + } + + QByteArray contentData = file.readAll(); + file.close(); + + return contentData; +} + +QString ChatFileStore::loadContentFromStorage( + const QString &chatFilePath, const QString &storedPath) +{ + return QString::fromLatin1(loadRawContentFromStorage(chatFilePath, storedPath).toBase64()); +} + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatFileStore.hpp b/sources/ChatView/ChatFileStore.hpp new file mode 100644 index 0000000..2c7131b --- /dev/null +++ b/sources/ChatView/ChatFileStore.hpp @@ -0,0 +1,89 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include +#include +#include + +#include "acp/AgentBinding.hpp" +#include "session/ConversationHistory.hpp" + +namespace QodeAssist::Session { +class Session; +} + +namespace QodeAssist::Chat { + +struct SerializationResult +{ + bool success{false}; + QString errorMessage; + QString warningMessage; +}; + +class ChatFileStore : public QObject +{ + Q_OBJECT + +public: + explicit ChatFileStore(Session::Session *session, QObject *parent = nullptr); + + QString historyDir() const; + QString suggestedFileName() const; + QString autosaveFilePath(const QString &recentFilePath) const; + QString autosaveFilePath( + const QString &recentFilePath, + const QString &firstMessage, + bool hasImageAttachments) const; + + using BindingReader = std::function; + using BindingWriter = std::function; + + void setBindingReader(BindingReader reader); + void setBindingWriter(BindingWriter writer); + + SerializationResult save(const QString &filePath) const; + SerializationResult load(const QString &filePath) const; + + void showSaveDialog(); + void showLoadDialog(); + void openHistoryFolder() const; + + static SerializationResult saveToFile( + const Session::ConversationHistory &history, + const Acp::AgentBinding &binding, + const QString &filePath); + static SerializationResult loadFromFile( + Session::ConversationHistory &history, + Acp::AgentBinding &binding, + const QString &filePath); + + static QString getChatContentFolder(const QString &chatFilePath); + static bool saveContentToStorage( + const QString &chatFilePath, + const QString &fileName, + const QString &base64Data, + QString &storedPath); + static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath); + static QByteArray loadRawContentFromStorage( + const QString &chatFilePath, const QString &storedPath); + +signals: + void saveRequested(const QString &filePath); + void loadRequested(const QString &filePath); + +private: + QString generateChatFileName(const QString &shortMessage, const QString &dir) const; + static bool ensureDirectoryExists(const QString &filePath); + + QPointer m_session; + BindingReader m_bindingReader; + BindingWriter m_bindingWriter; +}; + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryBridge.cpp b/sources/ChatView/ChatHistoryBridge.cpp deleted file mode 100644 index 233bf44..0000000 --- a/sources/ChatView/ChatHistoryBridge.cpp +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (C) 2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ChatHistoryBridge.hpp" - -#include "ChatModel.hpp" -#include "session/Session.hpp" - -namespace QodeAssist::Chat { - -namespace { - -ChatModel::ChatRole toChatRole(Session::RowKind kind) -{ - switch (kind) { - case Session::RowKind::System: - return ChatModel::ChatRole::System; - case Session::RowKind::User: - return ChatModel::ChatRole::User; - case Session::RowKind::Assistant: - return ChatModel::ChatRole::Assistant; - case Session::RowKind::Tool: - return ChatModel::ChatRole::Tool; - case Session::RowKind::FileEdit: - return ChatModel::ChatRole::FileEdit; - case Session::RowKind::Thinking: - return ChatModel::ChatRole::Thinking; - } - return ChatModel::ChatRole::Assistant; -} - -ChatModel::Message toChatMessage(const Session::MessageRow &row) -{ - ChatModel::Message message; - message.role = toChatRole(row.kind); - message.content = row.content; - message.id = row.id; - message.isRedacted = row.redacted; - message.signature = row.signature; - message.toolName = row.toolName; - message.toolArguments = row.toolArguments; - message.toolResult = row.toolResult; - - for (const Session::AttachmentBlock &attachment : row.attachments) - message.attachments.append(Context::ContentFile{attachment.fileName, attachment.storedPath}); - - for (const Session::ImageBlock &image : row.images) - message.images.append( - ChatModel::ImageAttachment{image.fileName, image.storedPath, image.mediaType}); - - message.promptTokens = row.usage.promptTokens; - message.completionTokens = row.usage.completionTokens; - message.cachedPromptTokens = row.usage.cachedPromptTokens; - message.reasoningTokens = row.usage.reasoningTokens; - - return message; -} - -QVector toChatMessages(const QList &rows) -{ - QVector messages; - messages.reserve(rows.size()); - for (const Session::MessageRow &row : rows) - messages.append(toChatMessage(row)); - return messages; -} - -} // namespace - -ChatHistoryBridge::ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent) - : QObject(parent) - , m_model(model) -{ - connect(session, &Session::Session::rowsReset, this, &ChatHistoryBridge::onRowsReset); - connect(session, &Session::Session::rowsAppended, this, &ChatHistoryBridge::onRowsAppended); - connect(session, &Session::Session::rowUpdated, this, &ChatHistoryBridge::onRowUpdated); - connect(session, &Session::Session::rowsRemoved, this, &ChatHistoryBridge::onRowsRemoved); - - onRowsReset(session->rows()); -} - -void ChatHistoryBridge::onRowsReset(const QList &rows) -{ - if (m_model) - m_model->resetMessages(toChatMessages(rows)); -} - -void ChatHistoryBridge::onRowsAppended(const QList &rows) -{ - if (m_model) - m_model->appendMessages(toChatMessages(rows)); -} - -void ChatHistoryBridge::onRowUpdated(int index, const Session::MessageRow &row) -{ - if (m_model) - m_model->updateMessage(index, toChatMessage(row)); -} - -void ChatHistoryBridge::onRowsRemoved(int first, int count) -{ - if (m_model) - m_model->removeMessages(first, count); -} - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryBridge.hpp b/sources/ChatView/ChatHistoryBridge.hpp deleted file mode 100644 index 5f21b1f..0000000 --- a/sources/ChatView/ChatHistoryBridge.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (C) 2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -#include "session/HistoryProjection.hpp" - -namespace QodeAssist::Session { -class Session; -} - -namespace QodeAssist::Chat { - -class ChatModel; - -class ChatHistoryBridge : public QObject -{ - Q_OBJECT - -public: - ChatHistoryBridge(Session::Session *session, ChatModel *model, QObject *parent = nullptr); - -private: - void onRowsReset(const QList &rows); - void onRowsAppended(const QList &rows); - void onRowUpdated(int index, const Session::MessageRow &row); - void onRowsRemoved(int first, int count); - - QPointer m_model; -}; - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryStore.cpp b/sources/ChatView/ChatHistoryStore.cpp deleted file mode 100644 index 12ffcc2..0000000 --- a/sources/ChatView/ChatHistoryStore.cpp +++ /dev/null @@ -1,236 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ChatHistoryStore.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#include "Logger.hpp" -#include "ProjectSettings.hpp" -#include "session/Session.hpp" - -namespace QodeAssist::Chat { - -ChatHistoryStore::ChatHistoryStore(Session::Session *session, QObject *parent) - : QObject(parent) - , m_session(session) -{} - -QString ChatHistoryStore::historyDir() const -{ - QString path; - - if (auto project = ProjectExplorer::ProjectManager::startupProject()) { - Settings::ProjectSettings projectSettings(project); - path = projectSettings.chatHistoryPath().toFSPathString(); - } else { - QDir baseDir(Core::ICore::userResourcePath().toFSPathString()); - path = baseDir.filePath("qodeassist/chat_history"); - } - - QDir dir(path); - if (!dir.exists() && !dir.mkpath(".")) { - LOG_MESSAGE(QString("Failed to create directory: %1").arg(path)); - return QString(); - } - - return path; -} - -QString ChatHistoryStore::suggestedFileName() const -{ - QString shortMessage; - - if (!m_session) - return generateChatFileName(shortMessage, historyDir()); - - const QList &rows = m_session->rows(); - if (!rows.isEmpty()) { - shortMessage = rows.first().content.split('\n').first().simplified().left(30); - - if (shortMessage.isEmpty() && !rows.first().images.isEmpty()) - shortMessage = "image_chat"; - } - - return generateChatFileName(shortMessage, historyDir()); -} - -QString ChatHistoryStore::autosaveFilePath(const QString &recentFilePath) const -{ - if (!recentFilePath.isEmpty()) { - return recentFilePath; - } - - QString dir = historyDir(); - if (dir.isEmpty()) { - return QString(); - } - - return QDir(dir).filePath(suggestedFileName() + ".json"); -} - -QString ChatHistoryStore::autosaveFilePath( - const QString &recentFilePath, const QString &firstMessage, bool hasImageAttachments) const -{ - if (!recentFilePath.isEmpty()) { - return recentFilePath; - } - - QString dir = historyDir(); - if (dir.isEmpty()) { - return QString(); - } - - QString shortMessage = firstMessage.split('\n').first().simplified().left(30); - - if (shortMessage.isEmpty() && hasImageAttachments) { - shortMessage = "image_chat"; - } - - QString fileName = generateChatFileName(shortMessage, dir); - return QDir(dir).filePath(fileName + ".json"); -} - -SerializationResult ChatHistoryStore::save(const QString &filePath) const -{ - if (!m_session) - return {false, QString("Chat session is no longer available")}; - - return ChatSerializer::saveToFile(m_session->history(), filePath); -} - -SerializationResult ChatHistoryStore::load(const QString &filePath) const -{ - if (!m_session) - return {false, QString("Chat session is no longer available")}; - - Session::ConversationHistory history; - const SerializationResult result = ChatSerializer::loadFromFile(history, filePath); - if (result.success) - m_session->setHistory(history); - return result; -} - -void ChatHistoryStore::showSaveDialog() -{ - QString initialDir = historyDir(); - - QFileDialog *dialog = new QFileDialog(nullptr, tr("Save Chat History")); - dialog->setAcceptMode(QFileDialog::AcceptSave); - dialog->setFileMode(QFileDialog::AnyFile); - dialog->setNameFilter(tr("JSON files (*.json)")); - dialog->setDefaultSuffix("json"); - if (!initialDir.isEmpty()) { - dialog->setDirectory(initialDir); - dialog->selectFile(suggestedFileName() + ".json"); - } - - connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) { - if (result == QFileDialog::Accepted) { - QStringList files = dialog->selectedFiles(); - if (!files.isEmpty()) { - emit saveRequested(files.first()); - } - } - dialog->deleteLater(); - }); - - dialog->open(); -} - -void ChatHistoryStore::showLoadDialog() -{ - QString initialDir = historyDir(); - - QFileDialog *dialog = new QFileDialog(nullptr, tr("Load Chat History")); - dialog->setAcceptMode(QFileDialog::AcceptOpen); - dialog->setFileMode(QFileDialog::ExistingFile); - dialog->setNameFilter(tr("JSON files (*.json)")); - if (!initialDir.isEmpty()) { - dialog->setDirectory(initialDir); - } - - connect(dialog, &QFileDialog::finished, this, [this, dialog](int result) { - if (result == QFileDialog::Accepted) { - QStringList files = dialog->selectedFiles(); - if (!files.isEmpty()) { - emit loadRequested(files.first()); - } - } - dialog->deleteLater(); - }); - - dialog->open(); -} - -void ChatHistoryStore::openHistoryFolder() const -{ - QString path; - if (auto project = ProjectExplorer::ProjectManager::startupProject()) { - Settings::ProjectSettings projectSettings(project); - path = projectSettings.chatHistoryPath().toFSPathString(); - } else { - QDir baseDir(Core::ICore::userResourcePath().toFSPathString()); - path = baseDir.filePath("qodeassist/chat_history"); - } - - QDir dir(path); - if (!dir.exists()) { - dir.mkpath("."); - } - - QUrl url = QUrl::fromLocalFile(dir.absolutePath()); - QDesktopServices::openUrl(url); -} - -QString ChatHistoryStore::generateChatFileName(const QString &shortMessage, const QString &dir) const -{ - static const QRegularExpression saitizeSymbols = QRegularExpression("[\\/:*?\"<>|\\s]"); - static const QRegularExpression underSymbols = QRegularExpression("_+"); - - QStringList parts; - QString sanitizedMessage = shortMessage; - sanitizedMessage.replace(saitizeSymbols, "_"); - sanitizedMessage.replace(underSymbols, "_"); - sanitizedMessage = sanitizedMessage.trimmed(); - - if (!sanitizedMessage.isEmpty()) { - if (sanitizedMessage.startsWith('_')) { - sanitizedMessage.remove(0, 1); - } - if (sanitizedMessage.endsWith('_')) { - sanitizedMessage.chop(1); - } - - QString fullPath = QDir(dir).filePath(sanitizedMessage); - QFileInfo fileInfo(fullPath); - if (!fileInfo.exists() && QFileInfo(fileInfo.path()).isWritable()) { - parts << sanitizedMessage; - } - } - - parts << QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm"); - - QString fileName = parts.join("_"); - QString fullPath = QDir(dir).filePath(fileName); - QFileInfo finalCheck(fullPath); - - if (fileName.isEmpty() || finalCheck.exists() || !QFileInfo(finalCheck.path()).isWritable()) { - fileName = QString("chat_%1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm")); - } - - return fileName; -} - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatHistoryStore.hpp b/sources/ChatView/ChatHistoryStore.hpp deleted file mode 100644 index bd16f82..0000000 --- a/sources/ChatView/ChatHistoryStore.hpp +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -#include "ChatSerializer.hpp" - -namespace QodeAssist::Session { -class Session; -} - -namespace QodeAssist::Chat { - -class ChatHistoryStore : public QObject -{ - Q_OBJECT - -public: - explicit ChatHistoryStore(Session::Session *session, QObject *parent = nullptr); - - QString historyDir() const; - QString suggestedFileName() const; - QString autosaveFilePath(const QString &recentFilePath) const; - QString autosaveFilePath( - const QString &recentFilePath, - const QString &firstMessage, - bool hasImageAttachments) const; - - SerializationResult save(const QString &filePath) const; - SerializationResult load(const QString &filePath) const; - - void showSaveDialog(); - void showLoadDialog(); - void openHistoryFolder() const; - -signals: - void saveRequested(const QString &filePath); - void loadRequested(const QString &filePath); - -private: - QString generateChatFileName(const QString &shortMessage, const QString &dir) const; - - QPointer m_session; -}; - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatModel.cpp b/sources/ChatView/ChatModel.cpp index 145e623..b33c753 100644 --- a/sources/ChatView/ChatModel.cpp +++ b/sources/ChatView/ChatModel.cpp @@ -10,7 +10,6 @@ #include #include -#include #include "logger/Logger.hpp" @@ -18,19 +17,33 @@ namespace QodeAssist::Chat { namespace { -auto usageOf(const ChatModel::Message &message) +ChatModel::ChatRole toChatRole(Session::RowKind kind) { - return std::tie( - message.promptTokens, - message.completionTokens, - message.cachedPromptTokens, - message.reasoningTokens); + switch (kind) { + case Session::RowKind::System: + return ChatModel::ChatRole::System; + case Session::RowKind::User: + return ChatModel::ChatRole::User; + case Session::RowKind::Assistant: + return ChatModel::ChatRole::Assistant; + case Session::RowKind::Tool: + case Session::RowKind::AgentTool: + return ChatModel::ChatRole::Tool; + case Session::RowKind::FileEdit: + return ChatModel::ChatRole::FileEdit; + case Session::RowKind::Thinking: + return ChatModel::ChatRole::Thinking; + case Session::RowKind::Permission: + return ChatModel::ChatRole::Permission; + case Session::RowKind::Plan: + return ChatModel::ChatRole::Plan; + } + return ChatModel::ChatRole::Assistant; } -bool carriesUsage(const ChatModel::Message &message) +bool carriesUsage(const Session::MessageRow &row) { - return message.promptTokens != 0 || message.completionTokens != 0 - || message.cachedPromptTokens != 0 || message.reasoningTokens != 0; + return !row.usage.isEmpty(); } } // namespace @@ -49,10 +62,10 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const if (!index.isValid() || index.row() >= m_messages.size()) return QVariant(); - const Message &message = m_messages[index.row()]; + const Session::MessageRow &message = m_messages[index.row()]; switch (static_cast(role)) { case Roles::RoleType: - return QVariant::fromValue(message.role); + return QVariant::fromValue(toChatRole(message.kind)); case Roles::Content: { return message.content; } @@ -60,37 +73,47 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const QVariantList attachmentsList; for (const auto &attachment : message.attachments) { QVariantMap attachmentMap; - attachmentMap["fileName"] = attachment.filename; - attachmentMap["storedPath"] = attachment.content; - + attachmentMap["fileName"] = attachment.fileName; + attachmentMap["storedPath"] = attachment.storedPath; + if (!m_chatFilePath.isEmpty()) { QFileInfo fileInfo(m_chatFilePath); QString baseName = fileInfo.completeBaseName(); QString dirPath = fileInfo.absolutePath(); QString contentFolder = QDir(dirPath).filePath(baseName + "_content"); - QString fullPath = QDir(contentFolder).filePath(attachment.content); + QString fullPath = QDir(contentFolder).filePath(attachment.storedPath); attachmentMap["filePath"] = fullPath; } else { attachmentMap["filePath"] = QString(); } - + attachmentsList.append(attachmentMap); } return attachmentsList; } case Roles::IsRedacted: { - return message.isRedacted; + return message.redacted; } case Roles::PromptTokens: - return message.promptTokens; + return message.usage.promptTokens; case Roles::CompletionTokens: - return message.completionTokens; + return message.usage.completionTokens; case Roles::CachedPromptTokens: - return message.cachedPromptTokens; + return message.usage.cachedPromptTokens; case Roles::ReasoningTokens: - return message.reasoningTokens; + return message.usage.reasoningTokens; case Roles::TotalTokens: - return message.promptTokens + message.completionTokens; + return message.usage.promptTokens + message.usage.completionTokens; + case Roles::ToolKind: + return message.toolKind; + case Roles::ToolStatus: + return message.toolStatus; + case Roles::ToolName: + return message.toolName; + case Roles::ToolResult: + return message.toolResult; + case Roles::ToolDetails: + return QVariant::fromValue(message.toolDetails); case Roles::Images: { QVariantList imagesList; for (const auto &image : message.images) { @@ -98,7 +121,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const imageMap["fileName"] = image.fileName; imageMap["storedPath"] = image.storedPath; imageMap["mediaType"] = image.mediaType; - + if (!m_chatFilePath.isEmpty()) { QFileInfo fileInfo(m_chatFilePath); QString baseName = fileInfo.completeBaseName(); @@ -111,7 +134,7 @@ QVariant ChatModel::data(const QModelIndex &index, int role) const imageMap["imageUrl"] = QString(); imageMap["filePath"] = QString(); } - + imagesList.append(imageMap); } return imagesList; @@ -134,32 +157,37 @@ QHash ChatModel::roleNames() const roles[Roles::CachedPromptTokens] = "cachedPromptTokens"; roles[Roles::ReasoningTokens] = "reasoningTokens"; roles[Roles::TotalTokens] = "totalTokens"; + roles[Roles::ToolKind] = "toolKind"; + roles[Roles::ToolStatus] = "toolStatus"; + roles[Roles::ToolDetails] = "toolDetails"; + roles[Roles::ToolName] = "toolName"; + roles[Roles::ToolResult] = "toolResult"; return roles; } -void ChatModel::resetMessages(const QVector &messages) +void ChatModel::resetMessages(const QList &rows) { beginResetModel(); - m_messages = messages; + m_messages = rows; endResetModel(); emit modelReseted(); emit sessionUsageChanged(); } -void ChatModel::appendMessages(const QVector &messages) +void ChatModel::appendMessages(const QList &rows) { - if (messages.isEmpty()) + if (rows.isEmpty()) return; - beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + messages.size() - 1); - m_messages.append(messages); + beginInsertRows(QModelIndex(), m_messages.size(), m_messages.size() + rows.size() - 1); + m_messages.append(rows); endInsertRows(); - if (std::any_of(messages.cbegin(), messages.cend(), carriesUsage)) + if (std::any_of(rows.cbegin(), rows.cend(), carriesUsage)) emit sessionUsageChanged(); } -void ChatModel::updateMessage(int index, const Message &message) +void ChatModel::updateMessage(int index, const Session::MessageRow &row) { if (index < 0 || index >= m_messages.size()) { LOG_MESSAGE(QString("Session/model desync: update of row %1 with %2 rows present") @@ -168,8 +196,8 @@ void ChatModel::updateMessage(int index, const Message &message) return; } - const bool usageChanged = usageOf(m_messages[index]) != usageOf(message); - m_messages[index] = message; + const bool usageChanged = m_messages[index].usage != row.usage; + m_messages[index] = row; emit dataChanged(this->index(index), this->index(index)); if (usageChanged) @@ -262,7 +290,7 @@ QVariantList ChatModel::userMessagePreviews(int maxLength) const QVariantList result; const int limit = maxLength > 4 ? maxLength : 80; for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role != ChatRole::User) + if (m_messages[i].kind != Session::RowKind::User) continue; QString preview = m_messages[i].content; preview.replace(QLatin1Char('\n'), QLatin1Char(' ')); @@ -283,7 +311,7 @@ int ChatModel::sessionPromptTokens() const { int total = 0; for (const auto &m : m_messages) - total += m.promptTokens; + total += m.usage.promptTokens; return total; } @@ -291,7 +319,7 @@ int ChatModel::sessionCompletionTokens() const { int total = 0; for (const auto &m : m_messages) - total += m.completionTokens; + total += m.usage.completionTokens; return total; } @@ -299,7 +327,7 @@ int ChatModel::sessionCachedPromptTokens() const { int total = 0; for (const auto &m : m_messages) - total += m.cachedPromptTokens; + total += m.usage.cachedPromptTokens; return total; } diff --git a/sources/ChatView/ChatModel.hpp b/sources/ChatView/ChatModel.hpp index 2b049c6..fcc5c4b 100644 --- a/sources/ChatView/ChatModel.hpp +++ b/sources/ChatView/ChatModel.hpp @@ -7,10 +7,9 @@ #include "MessagePart.hpp" #include -#include #include -#include "context/ContentFile.hpp" +#include "session/HistoryProjection.hpp" namespace QodeAssist::Chat { @@ -24,7 +23,7 @@ class ChatModel : public QAbstractListModel QML_ELEMENT public: - enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking }; + enum ChatRole { System, User, Assistant, Tool, FileEdit, Thinking, Permission, Plan }; Q_ENUM(ChatRole) enum Roles { @@ -37,47 +36,24 @@ public: CompletionTokens, CachedPromptTokens, ReasoningTokens, - TotalTokens + TotalTokens, + ToolKind, + ToolStatus, + ToolDetails, + ToolName, + ToolResult }; Q_ENUM(Roles) - struct ImageAttachment - { - QString fileName; // Original filename - QString storedPath; // Path to stored image file (relative to chat folder) - QString mediaType; // MIME type - }; - - struct Message - { - ChatRole role; - QString content; - QString id; - bool isRedacted = false; - QString signature = QString(); - - QList attachments; - QList images; - - QString toolName; - QJsonObject toolArguments; - QString toolResult; - - int promptTokens = 0; - int completionTokens = 0; - int cachedPromptTokens = 0; - int reasoningTokens = 0; - }; - explicit ChatModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; - void resetMessages(const QVector &messages); - void appendMessages(const QVector &messages); - void updateMessage(int index, const Message &message); + void resetMessages(const QList &rows); + void appendMessages(const QList &rows); + void updateMessage(int index, const Session::MessageRow &row); void removeMessages(int first, int count); Q_INVOKABLE QList processMessageContent(const QString &content) const; @@ -96,10 +72,9 @@ signals: void sessionUsageChanged(); private: - QVector m_messages; + QList m_messages; QString m_chatFilePath; }; } // namespace QodeAssist::Chat -Q_DECLARE_METATYPE(QodeAssist::Chat::ChatModel::Message) Q_DECLARE_METATYPE(QodeAssist::Chat::MessagePart) diff --git a/sources/ChatView/ChatRootView.cpp b/sources/ChatView/ChatRootView.cpp index 1711d36..34d688b 100644 --- a/sources/ChatView/ChatRootView.cpp +++ b/sources/ChatView/ChatRootView.cpp @@ -6,8 +6,6 @@ #include #include -#include -#include #include #include #include @@ -16,6 +14,7 @@ #include #include #include +#include #include #include @@ -28,20 +27,19 @@ #include "plugin/QodeAssistConstants.hpp" -#include "AgentRoleController.hpp" #include "ChatAssistantSettings.hpp" #include "ChatConfigurationController.hpp" +#include "acp/AgentCatalogStore.hpp" #include "ChatCompressor.hpp" -#include "ChatHistoryStore.hpp" +#include "ChatFileStore.hpp" #include "FileEditController.hpp" #include "GeneralSettings.hpp" #include "InputTokenCounter.hpp" #include "SettingsConstants.hpp" #include "Logger.hpp" -#include "providers/ProvidersManager.hpp" #include "SessionFileRegistry.hpp" #include "context/ContextManager.hpp" -#include "context/RulesLoader.hpp" +#include "TurnContextAdapters.hpp" #include "ProjectSettings.hpp" #include "SkillsSettings.hpp" #include "skills/SkillsManager.hpp" @@ -49,12 +47,6 @@ namespace QodeAssist::Chat { namespace { -bool isChatEditor(Core::IEditor *editor) -{ - return editor && editor->document() - && editor->document()->id() == Utils::Id(Constants::QODE_ASSIST_CHAT_EDITOR_ID); -} - QKeySequence sendMessageKeySequence() { auto command = Core::ActionManager::command(Constants::QODE_ASSIST_CHAT_SEND_MESSAGE); @@ -76,22 +68,16 @@ ChatRootView::ChatRootView(QQuickItem *parent) , m_chatModel(new ChatModel(this)) , m_promptProvider(Templates::PromptTemplateManager::instance()) , m_controller(new ChatController(m_chatModel, &m_promptProvider, this)) - , m_fileManager(new ChatFileManager(this)) + , m_attachmentStaging(new AttachmentStaging(this)) , m_isRequestInProgress(false) , m_chatCompressor(new ChatCompressor(this)) - , m_agentRoleController(new AgentRoleController(this)) , m_configurationController(new ChatConfigurationController(this)) , m_fileEditController(new FileEditController(this)) , m_tokenCounter(new InputTokenCounter( m_controller->session(), m_controller->contextManager(), this)) - , m_historyStore(new ChatHistoryStore(m_controller->session(), this)) + , m_historyStore(new ChatFileStore(m_controller->session(), this)) { - m_isSyncOpenFiles = Settings::chatAssistantSettings().linkOpenFiles(); - connect( - &Settings::chatAssistantSettings().linkOpenFiles, - &Utils::BaseAspect::changed, - this, - [this]() { setIsSyncOpenFiles(Settings::chatAssistantSettings().linkOpenFiles()); }); + m_coordinator = new ConversationCoordinator({m_controller, this, this, this}, this); QMetaObject::invokeMethod( this, @@ -124,6 +110,52 @@ ChatRootView::ChatRootView(QQuickItem *parent) &ChatConfigurationController::currentConfigurationChanged, this, &ChatRootView::currentConfigurationChanged); + connect( + m_configurationController, + &ChatConfigurationController::agentRequested, + m_coordinator, + &ConversationCoordinator::chooseAgent); + connect( + m_configurationController, + &ChatConfigurationController::llmRequested, + m_coordinator, + &ConversationCoordinator::chooseLlm); + + connect( + m_coordinator, + &ConversationCoordinator::switchConfirmationNeeded, + this, + &ChatRootView::chatTargetSwitchNeedsNewChat); + connect( + m_coordinator, + &ConversationCoordinator::switchCancelled, + this, + &ChatRootView::currentConfigurationChanged); + connect( + m_coordinator, + &ConversationCoordinator::boundToAgent, + this, + [this](const Acp::AgentDefinition &agent) { + m_configurationController->setBoundAgent(agent); + emit isAgentBoundChanged(); + }); + connect(m_coordinator, &ConversationCoordinator::boundToLlm, this, [this] { + m_configurationController->clearBoundAgent(); + emit isAgentBoundChanged(); + }); + connect( + m_coordinator, + &ConversationCoordinator::sessionIssueChanged, + this, + &ChatRootView::agentSessionIssueChanged); + connect( + m_coordinator, + &ConversationCoordinator::errorSurfaced, + this, + [this](const QString &message) { + m_lastErrorMessage = message; + emit lastErrorMessageChanged(); + }); connect( m_controller, @@ -143,6 +175,7 @@ ChatRootView::ChatRootView(QQuickItem *parent) connect(m_chatModel, &ChatModel::modelReseted, this, [this]() { setRecentFilePath(QString{}); + m_coordinator->conversationReset(); m_fileEditController->clearCurrentRequestId(); }); auto maybeEmitTitle = [this] { @@ -152,55 +185,83 @@ ChatRootView::ChatRootView(QQuickItem *parent) m_cachedChatTitle = newTitle; emit chatTitleChanged(); }; - connect(m_chatModel, &ChatModel::modelReseted, this, maybeEmitTitle); + connect( + m_controller, + &ChatController::sessionInfoReceived, + m_coordinator, + &ConversationCoordinator::titleSuggested); + connect(m_coordinator, &ConversationCoordinator::agentTitleChanged, this, maybeEmitTitle); + + connect( + m_controller, + &ChatController::agentSessionUnavailable, + m_coordinator, + &ConversationCoordinator::agentSessionUnavailable); + + m_historyStore->setBindingReader([this] { return m_coordinator->bindingForSave(); }); + m_historyStore->setBindingWriter( + [this](const Acp::AgentBinding &binding) { m_coordinator->restoreAgentBinding(binding); }); connect(m_chatModel, &QAbstractItemModel::modelReset, this, maybeEmitTitle); connect(m_chatModel, &QAbstractItemModel::rowsInserted, this, maybeEmitTitle); connect(m_chatModel, &QAbstractItemModel::rowsRemoved, this, maybeEmitTitle); + + connect(this, &ChatRootView::isAgentBoundChanged, this, &ChatRootView::shrinkContextStateChanged); + connect( + m_controller, + &ChatController::agentCommandsChanged, + this, + &ChatRootView::slashCommandsChanged); + connect( + this, &ChatRootView::agentSessionIssueChanged, this, &ChatRootView::shrinkContextStateChanged); + connect( + m_chatModel, + &QAbstractItemModel::modelReset, + this, + &ChatRootView::shrinkContextStateChanged); + connect( + m_chatModel, + &QAbstractItemModel::rowsInserted, + this, + &ChatRootView::shrinkContextStateChanged); + connect( + m_chatModel, + &QAbstractItemModel::rowsRemoved, + this, + &ChatRootView::shrinkContextStateChanged); + connect( + &Settings::generalSettings().caProvider, + &Utils::BaseAspect::changed, + this, + &ChatRootView::shrinkContextStateChanged); + connect( + &Settings::generalSettings().caModel, + &Utils::BaseAspect::changed, + this, + &ChatRootView::shrinkContextStateChanged); + connect( + &Settings::generalSettings().caTemplate, + &Utils::BaseAspect::changed, + this, + &ChatRootView::shrinkContextStateChanged); + connect( + &Settings::generalSettings().caUrl, + &Utils::BaseAspect::changed, + this, + &ChatRootView::shrinkContextStateChanged); connect(this, &ChatRootView::attachmentFilesChanged, this, [this]() { m_tokenCounter->setAttachments(m_attachmentFiles); }); - connect(this, &ChatRootView::linkedFilesChanged, this, [this]() { - m_tokenCounter->setLinkedFiles(m_linkedFiles); - }); - connect(this, &ChatRootView::useToolsChanged, this, &ChatRootView::updateInputTokensCount); - connect( m_tokenCounter, &InputTokenCounter::inputTokensChanged, this, &ChatRootView::inputTokensCountChanged); connect( - m_agentRoleController, - &AgentRoleController::availableRolesChanged, - this, - &ChatRootView::availableAgentRolesChanged); - connect( - m_agentRoleController, - &AgentRoleController::currentRoleChanged, - this, - &ChatRootView::currentAgentRoleChanged); - connect( - m_agentRoleController, - &AgentRoleController::baseSystemPromptChanged, + &Settings::chatAssistantSettings().systemPrompt, + &Utils::BaseAspect::changed, this, &ChatRootView::baseSystemPromptChanged); - auto editors = Core::EditorManager::instance(); - - connect(editors, &Core::EditorManager::editorCreated, this, &ChatRootView::onEditorCreated); - connect( - editors, - &Core::EditorManager::editorAboutToClose, - this, - &ChatRootView::onEditorAboutToClose); - - connect(editors, &Core::EditorManager::currentEditorAboutToChange, this, [this]() { - if (m_isSyncOpenFiles) { - for (auto editor : std::as_const(m_currentEditors)) { - onAppendLinkFileFromEditor(editor); - } - } - }); connect( &Settings::chatAssistantSettings().textFontFamily, &Utils::BaseAspect::changed, @@ -261,49 +322,11 @@ ChatRootView::ChatRootView(QQuickItem *parent) }); connect( - m_historyStore, &ChatHistoryStore::saveRequested, this, &ChatRootView::saveHistory); + m_historyStore, &ChatFileStore::saveRequested, this, &ChatRootView::saveHistory); connect( - m_historyStore, &ChatHistoryStore::loadRequested, this, &ChatRootView::loadHistory); + m_historyStore, &ChatFileStore::loadRequested, this, &ChatRootView::loadHistory); - refreshRules(); - - connect( - ProjectExplorer::ProjectManager::instance(), - &ProjectExplorer::ProjectManager::startupProjectChanged, - this, - &ChatRootView::refreshRules); - - connect( - ProjectExplorer::ProjectManager::instance(), - &ProjectExplorer::ProjectManager::projectAdded, - this, - &ChatRootView::openFilesChanged); - - connect( - ProjectExplorer::ProjectManager::instance(), - &ProjectExplorer::ProjectManager::projectRemoved, - this, - &ChatRootView::openFilesChanged); - - connect( - &Settings::chatAssistantSettings().enableChatTools, - &Utils::BaseAspect::changed, - this, - &ChatRootView::useToolsChanged); - - connect( - &Settings::chatAssistantSettings().enableThinkingMode, - &Utils::BaseAspect::changed, - this, - &ChatRootView::useThinkingChanged); - - connect( - &Settings::generalSettings().caProvider, - &Utils::BaseAspect::changed, - this, - &ChatRootView::isThinkingSupportChanged); - - connect(m_fileManager, &ChatFileManager::fileOperationFailed, this, [this](const QString &error) { + connect(m_attachmentStaging, &AttachmentStaging::fileOperationFailed, this, [this](const QString &error) { m_lastErrorMessage = error; emit lastErrorMessageChanged(); }); @@ -320,24 +343,28 @@ ChatRootView::ChatRootView(QQuickItem *parent) loadHistory(compressedChatPath); - if (m_pendingSend.active) { - PendingSend p = m_pendingSend; - m_pendingSend = {}; - dispatchSend(p.message, p.attachments, p.linkedFiles, p.useTools, p.useThinking); - } + m_coordinator->compressionSettled(); }); + connect( + m_chatCompressor, + &ChatCompressor::compressingChanged, + this, + &ChatRootView::isCompressingChanged); + + connect( + m_chatCompressor, + &ChatCompressor::summaryReady, + m_coordinator, + &ConversationCoordinator::summaryProduced); + connect(m_chatCompressor, &ChatCompressor::compressionFailed, this, [this](const QString &error) { emit isCompressingChanged(); m_lastErrorMessage = error; emit lastErrorMessageChanged(); emit compressionFailed(error); - if (m_pendingSend.active) { - PendingSend p = m_pendingSend; - m_pendingSend = {}; - dispatchSend(p.message, p.attachments, p.linkedFiles, p.useTools, p.useThinking); - } + m_coordinator->compressionSettled(); }); } @@ -348,6 +375,18 @@ ChatRootView::~ChatRootView() } } +void ChatRootView::componentComplete() +{ + QQuickItem::componentComplete(); + + if (auto context = qmlContext(this)) { + if (auto *store = qobject_cast( + context->contextProperty("agentCatalog").value())) { + m_configurationController->setAgentCatalog(store); + } + } +} + SessionFileRegistry *ChatRootView::sessionFileRegistry() const { if (!m_sessionFileRegistryResolved) { @@ -372,6 +411,35 @@ Skills::SkillsManager *ChatRootView::skillsManager() const return m_skillsManager; } +QVariantList ChatRootView::searchSlashCommands(const QString &query) const +{ + if (isAgentBound()) + return searchAgentCommands(query); + return searchSkills(query); +} + +QVariantList ChatRootView::searchAgentCommands(const QString &query) const +{ + QVariantList results; + const QString source = m_controller->boundAgentName(); + const QString needle = query.trimmed().toLower(); + + const QList commands = m_controller->agentCommands(); + for (const LLMQore::Acp::AvailableCommand &command : commands) { + if (!needle.isEmpty() && !command.name.toLower().contains(needle) + && !command.description.toLower().contains(needle)) { + continue; + } + results.append( + QVariantMap{ + {QStringLiteral("name"), command.name}, + {QStringLiteral("description"), command.description}, + {QStringLiteral("inputHint"), command.inputHint}, + {QStringLiteral("source"), source}}); + } + return results; +} + QVariantList ChatRootView::searchSkills(const QString &query) const { QVariantList results; @@ -379,7 +447,7 @@ QVariantList ChatRootView::searchSkills(const QString &query) const if (!manager || !Settings::skillsSettings().enableSkills()) return results; - auto *project = Context::RulesLoader::getActiveProject(); + auto *project = activeProject(); QStringList projectSkillDirs; if (project) { Settings::ProjectSettings projectSettings(project); @@ -399,9 +467,12 @@ QVariantList ChatRootView::searchSkills(const QString &query) const && !skill.description.toLower().contains(needle)) { continue; } - results.append(QVariantMap{ - {QStringLiteral("name"), skill.name}, - {QStringLiteral("description"), skill.description}}); + results.append( + QVariantMap{ + {QStringLiteral("name"), skill.name}, + {QStringLiteral("description"), skill.description}, + {QStringLiteral("inputHint"), QString()}, + {QStringLiteral("source"), tr("QodeAssist")}}); } return results; } @@ -414,58 +485,40 @@ ChatModel *ChatRootView::chatModel() const void ChatRootView::sendMessage(const QString &message) { const QStringList attachments = m_attachmentFiles; - const QStringList linkedFiles = m_linkedFiles; - const bool tools = useTools(); - const bool thinking = useThinking(); - - if (deferSendForAutoCompress(message, attachments, linkedFiles, tools, thinking)) - return; - - dispatchSend(message, attachments, linkedFiles, tools, thinking); + m_coordinator->requestSend(message, attachments); } -bool ChatRootView::deferSendForAutoCompress( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles, - bool useToolsArg, - bool useThinkingArg) +bool ChatRootView::autoCompressEnabled() const { - auto &settings = Settings::chatAssistantSettings(); - if (!settings.autoCompress()) + return Settings::chatAssistantSettings().autoCompress(); +} + +int ChatRootView::autoCompressThreshold() const +{ + return Settings::chatAssistantSettings().autoCompressThreshold(); +} + +int ChatRootView::estimatedNextTokens() const +{ + return m_tokenCounter->inputTokens(); +} + +bool ChatRootView::prepareChatFileForCompression( + const QString &message, const QStringList &attachments) +{ + if (!m_recentFilePath.isEmpty()) + return true; + + const QString filePath = getAutosaveFilePath(message, attachments); + if (filePath.isEmpty()) return false; - const int threshold = settings.autoCompressThreshold(); - const int inputTokens = m_tokenCounter->inputTokens(); - if (inputTokens < threshold) - return false; - - if (m_recentFilePath.isEmpty()) { - QString filePath = getAutosaveFilePath(message, attachments); - if (filePath.isEmpty()) - return false; - setRecentFilePath(filePath); - LOG_MESSAGE(QString("Set chat file path for new chat (auto-compress): %1").arg(filePath)); - } - - if (m_chatCompressor->isCompressing() || m_pendingSend.active) - return false; - - LOG_MESSAGE(QString("Auto-compress preempt: estimated next=%1 ≥ threshold=%2; deferring send") - .arg(inputTokens) - .arg(threshold)); - - m_pendingSend = {message, attachments, linkedFiles, useToolsArg, useThinkingArg, true}; - compressCurrentChat(); + setRecentFilePath(filePath); + LOG_MESSAGE(QString("Set chat file path for new chat (auto-compress): %1").arg(filePath)); return true; } -void ChatRootView::dispatchSend( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles, - bool useToolsArg, - bool useThinkingArg) +void ChatRootView::dispatch(const QString &message, const QStringList &attachments) { if (m_recentFilePath.isEmpty()) { QString filePath = getAutosaveFilePath(message, attachments); @@ -482,9 +535,9 @@ void ChatRootView::dispatchSend( setRequestProgressStatus(true); m_controller->setSkillsManager(skillsManager()); - m_controller->sendMessage(message, attachments, linkedFiles, useToolsArg, useThinkingArg); + m_controller->sendMessage(message, attachments); - m_fileManager->clearIntermediateStorage(); + m_attachmentStaging->clearIntermediateStorage(); clearAttachmentFiles(); } @@ -507,27 +560,19 @@ void ChatRootView::clearAttachmentFiles() m_attachmentFiles.clear(); emit attachmentFilesChanged(); - m_fileManager->clearIntermediateStorage(); -} - -void ChatRootView::clearLinkedFiles() -{ - if (m_linkedFiles.isEmpty()) { - return; - } - - m_linkedFiles.clear(); - emit linkedFilesChanged(); + m_attachmentStaging->clearIntermediateStorage(); } void ChatRootView::clearMessages() { - m_controller->clearMessages(); - clearLinkedFiles(); + m_controller->clearConversation(); } void ChatRootView::resetChatToMessage(int index) { + if (m_coordinator->refuseWhileReadOnly()) + return; + m_controller->resetToRow(index); setRequestProgressStatus(false); } @@ -580,12 +625,10 @@ void ChatRootView::loadHistory(const QString &filePath) } } - if (!m_pendingSend.active) - m_fileManager->clearIntermediateStorage(); + if (!m_coordinator->hasDeferredSend()) + m_attachmentStaging->clearIntermediateStorage(); m_attachmentFiles.clear(); - m_linkedFiles.clear(); emit attachmentFilesChanged(); - emit linkedFilesChanged(); m_fileEditController->clearCurrentRequestId(); updateInputTokensCount(); @@ -638,11 +681,6 @@ QStringList ChatRootView::attachmentFiles() const return m_attachmentFiles; } -QStringList ChatRootView::linkedFiles() const -{ - return m_linkedFiles; -} - void ChatRootView::showAttachFilesDialog() { QFileDialog dialog(nullptr, tr("Select Files to Attach")); @@ -663,7 +701,7 @@ void ChatRootView::addFilesToAttachList(const QStringList &filePaths) return; } - const QStringList processedPaths = m_fileManager->processDroppedFiles(filePaths); + const QStringList processedPaths = m_attachmentStaging->processDroppedFiles(filePaths); bool filesAdded = false; for (const QString &filePath : processedPaths) { @@ -691,66 +729,6 @@ void ChatRootView::removeFileFromAttachList(int index) LOG_MESSAGE(QString("Removed attachment file: %1").arg(removedFile)); } -void ChatRootView::showLinkFilesDialog() -{ - QFileDialog dialog(nullptr, tr("Select Files to Attach")); - dialog.setFileMode(QFileDialog::ExistingFiles); - - if (auto project = ProjectExplorer::ProjectManager::startupProject()) { - dialog.setDirectory(project->projectDirectory().toFSPathString()); - } - - if (dialog.exec() == QDialog::Accepted) { - addFilesToLinkList(dialog.selectedFiles()); - } -} - -void ChatRootView::addFilesToLinkList(const QStringList &filePaths) -{ - if (filePaths.isEmpty()) { - return; - } - - bool filesAdded = false; - QStringList imageFiles; - - for (const QString &filePath : filePaths) { - if (isImageFile(filePath)) { - imageFiles.append(filePath); - continue; - } - - if (!m_linkedFiles.contains(filePath)) { - m_linkedFiles.append(filePath); - filesAdded = true; - } - } - - if (!imageFiles.isEmpty()) { - addFilesToAttachList(imageFiles); - m_lastInfoMessage - = tr("Images automatically moved to Attach zone (%n file(s))", "", imageFiles.size()); - emit lastInfoMessageChanged(); - } - - if (filesAdded) { - emit linkedFilesChanged(); - } -} - -void ChatRootView::removeFileFromLinkList(int index) -{ - if (index < 0 || index >= m_linkedFiles.size()) { - return; - } - - const QString removedFile = m_linkedFiles.at(index); - m_linkedFiles.removeAt(index); - emit linkedFilesChanged(); - - LOG_MESSAGE(QString("Removed linked file: %1").arg(removedFile)); -} - void ChatRootView::showAddImageDialog() { QFileDialog dialog(nullptr, tr("Select Images to Attach")); @@ -812,44 +790,11 @@ QString ChatRootView::sendShortcutText() const return sendMessageKeySequence().toString(QKeySequence::NativeText); } -void ChatRootView::setIsSyncOpenFiles(bool state) -{ - if (m_isSyncOpenFiles != state) { - m_isSyncOpenFiles = state; - emit isSyncOpenFilesChanged(); - } - - if (m_isSyncOpenFiles) { - for (auto editor : std::as_const(m_currentEditors)) { - onAppendLinkFileFromEditor(editor); - } - } -} - void ChatRootView::openChatHistoryFolder() { m_historyStore->openHistoryFolder(); } -void ChatRootView::openRulesFolder() -{ - auto project = ProjectExplorer::ProjectManager::startupProject(); - if (!project) { - return; - } - - QString projectPath = project->projectDirectory().toFSPathString(); - QString rulesPath = QDir(projectPath).filePath(".qodeassist/rules"); - - QDir dir(rulesPath); - if (!dir.exists()) { - dir.mkpath("."); - } - - QUrl url = QUrl::fromLocalFile(dir.absolutePath()); - QDesktopServices::openUrl(url); -} - void ChatRootView::openSettings() { QMetaObject::invokeMethod( @@ -900,6 +845,9 @@ QString ChatRootView::chatTitle() const QString ChatRootView::computeChatTitle() const { + if (!m_coordinator->agentTitle().isEmpty()) + return m_coordinator->agentTitle(); + for (const Session::MessageRow &row : m_controller->session()->rows()) { if (row.kind != Session::RowKind::User) continue; @@ -987,57 +935,6 @@ int ChatRootView::inputTokensCount() const return m_tokenCounter->inputTokens(); } -bool ChatRootView::isSyncOpenFiles() const -{ - return m_isSyncOpenFiles; -} - -void ChatRootView::onEditorAboutToClose(Core::IEditor *editor) -{ - if (isChatEditor(editor)) { - return; - } - - if (auto document = editor->document(); document && isSyncOpenFiles()) { - QString filePath = document->filePath().toFSPathString(); - m_linkedFiles.removeOne(filePath); - emit linkedFilesChanged(); - } - - if (editor) { - m_currentEditors.removeOne(editor); - } - - emit openFilesChanged(); -} - -void ChatRootView::onAppendLinkFileFromEditor(Core::IEditor *editor) -{ - if (isChatEditor(editor)) { - return; - } - - if (auto document = editor->document(); document && isSyncOpenFiles()) { - QString filePath = document->filePath().toFSPathString(); - if (!m_linkedFiles.contains(filePath) && !shouldIgnoreFileForAttach(document->filePath())) { - m_linkedFiles.append(filePath); - emit linkedFilesChanged(); - } - } -} - -void ChatRootView::onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath) -{ - if (isChatEditor(editor)) { - return; - } - - if (editor && editor->document()) { - m_currentEditors.append(editor); - emit openFilesChanged(); - } -} - QString ChatRootView::chatFileName() const { return QFileInfo(m_recentFilePath).baseName(); @@ -1065,25 +962,10 @@ void ChatRootView::setRecentFilePath(const QString &filePath) m_recentFilePath = filePath; m_controller->setChatFilePath(filePath); - m_fileManager->setChatFilePath(filePath); + m_attachmentStaging->setChatFilePath(filePath); emit chatFileNameChanged(); } -bool ChatRootView::shouldIgnoreFileForAttach(const Utils::FilePath &filePath) -{ - auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); - if (project - && m_controller->contextManager() - ->ignoreManager() - ->shouldIgnore(filePath.toFSPathString(), project)) { - LOG_MESSAGE(QString("Ignoring file for attachment due to .qodeassistignore: %1") - .arg(filePath.toFSPathString())); - return true; - } - - return false; -} - QString ChatRootView::textFontFamily() const { return Settings::chatAssistantSettings().textFontFamily.stringValue(); @@ -1127,85 +1009,32 @@ QString ChatRootView::lastErrorMessage() const return m_lastErrorMessage; } -QVariantList ChatRootView::activeRules() const +void ChatRootView::respondToPermission(const QString &requestId, const QString &optionId) { - return m_activeRules; -} - -int ChatRootView::activeRulesCount() const -{ - return m_activeRules.size(); -} - -QString ChatRootView::getRuleContent(int index) -{ - if (index < 0 || index >= m_activeRules.size()) - return QString(); - - return Context::RulesLoader::loadRuleFileContent( - m_activeRules[index].toMap()["filePath"].toString()); -} - -void ChatRootView::refreshRules() -{ - m_activeRules.clear(); - - auto project = Context::RulesLoader::getActiveProject(); - if (!project) { - emit activeRulesChanged(); - emit activeRulesCountChanged(); - return; - } - - auto ruleFiles - = Context::RulesLoader::getRuleFilesForProject(project, Context::RulesContext::Chat); - - for (const auto &ruleFile : ruleFiles) { - QVariantMap ruleMap; - ruleMap["filePath"] = ruleFile.filePath; - ruleMap["fileName"] = ruleFile.fileName; - ruleMap["category"] = ruleFile.category; - m_activeRules.append(ruleMap); - } - - emit activeRulesChanged(); - emit activeRulesCountChanged(); -} - -bool ChatRootView::useTools() const -{ - return Settings::chatAssistantSettings().enableChatTools(); -} - -void ChatRootView::setUseTools(bool enabled) -{ - Settings::chatAssistantSettings().enableChatTools.setValue(enabled); - Settings::chatAssistantSettings().writeSettings(); -} - -bool ChatRootView::useThinking() const -{ - return Settings::chatAssistantSettings().enableThinkingMode(); -} - -void ChatRootView::setUseThinking(bool enabled) -{ - Settings::chatAssistantSettings().enableThinkingMode.setValue(enabled); - Settings::chatAssistantSettings().writeSettings(); + m_controller->respondToPermission(requestId, optionId); } void ChatRootView::applyFileEdit(const QString &editId) { + if (m_coordinator->refuseWhileReadOnly()) + return; + m_fileEditController->applyFileEdit(editId); } void ChatRootView::rejectFileEdit(const QString &editId) { + if (m_coordinator->refuseWhileReadOnly()) + return; + m_fileEditController->rejectFileEdit(editId); } void ChatRootView::undoFileEdit(const QString &editId) { + if (m_coordinator->refuseWhileReadOnly()) + return; + m_fileEditController->undoFileEdit(editId); } @@ -1216,11 +1045,17 @@ void ChatRootView::openFileEditInEditor(const QString &editId) void ChatRootView::applyAllFileEditsForCurrentMessage() { + if (m_coordinator->refuseWhileReadOnly()) + return; + m_fileEditController->applyAllForCurrentMessage(); } void ChatRootView::undoAllFileEditsForCurrentMessage() { + if (m_coordinator->refuseWhileReadOnly()) + return; + m_fileEditController->undoAllForCurrentMessage(); } @@ -1254,14 +1089,6 @@ QString ChatRootView::lastInfoMessage() const return m_lastInfoMessage; } -bool ChatRootView::isThinkingSupport() const -{ - auto providerName = Settings::generalSettings().caProvider(); - auto provider = Providers::ProvidersManager::instance().getProviderByName(providerName); - - return provider && provider->capabilities().testFlag(Providers::ProviderCapability::Thinking); -} - bool ChatRootView::hasImageAttachments(const QStringList &attachments) const { for (const QString &filePath : attachments) { @@ -1290,57 +1117,72 @@ void ChatRootView::applyConfiguration(const QString &configName) m_configurationController->applyConfiguration(configName); } -QStringList ChatRootView::availableConfigurations() const +void ChatRootView::confirmChatTargetSwitch() { - return m_configurationController->availableConfigurations(); + m_coordinator->confirmSwitch(); } -QString ChatRootView::currentConfiguration() const +void ChatRootView::cancelChatTargetSwitch() { - return m_configurationController->currentConfiguration(); + m_coordinator->cancelSwitch(); } -void ChatRootView::loadAvailableAgentRoles() +bool ChatRootView::isAgentBound() const { - m_agentRoleController->loadAvailableRoles(); + return !m_controller->boundAgentId().isEmpty(); } -void ChatRootView::applyAgentRole(const QString &roleName) +QString ChatRootView::agentSessionIssue() const { - m_agentRoleController->applyRole(roleName); + return m_coordinator->sessionIssue(); } -QStringList ChatRootView::availableAgentRoles() const +bool ChatRootView::canStartNewAgentSession() const { - return m_agentRoleController->availableRoles(); + return m_coordinator->canStartFreshSession(); } -QString ChatRootView::currentAgentRole() const +void ChatRootView::startNewAgentSession() { - return m_agentRoleController->currentRole(); + m_coordinator->startFreshSession(); } -QString ChatRootView::baseSystemPrompt() const +bool ChatRootView::canHandOverSummary() const { - return m_agentRoleController->baseSystemPrompt(); + return m_coordinator->canHandOverSummary(); } -QString ChatRootView::currentAgentRoleDescription() const +QString ChatRootView::summaryHandoverTooltip() const { - return m_agentRoleController->currentRoleDescription(); + return m_coordinator->summaryHandoverTooltip(); } -QString ChatRootView::currentAgentRoleSystemPrompt() const +void ChatRootView::startNewAgentSessionWithSummary() { - return m_agentRoleController->currentRoleSystemPrompt(); + m_coordinator->handOverSummary(); } -void ChatRootView::openAgentRolesSettings() +std::optional ChatRootView::agentById(const QString &agentId) const { - m_agentRoleController->openSettings(); + return m_configurationController->agentById(agentId); } -void ChatRootView::compressCurrentChat() +QString ChatRootView::compressionConfigurationIssue() const +{ + return ChatCompressor::configurationIssue(); +} + +bool ChatRootView::isCompressionRunning() const +{ + return m_chatCompressor->isCompressing(); +} + +void ChatRootView::startTranscriptSummary() +{ + m_chatCompressor->startSummary(m_controller->session()->history()); +} + +void ChatRootView::startCompression() { if (m_chatCompressor->isCompressing()) { m_lastErrorMessage = tr("Compression is already in progress"); @@ -1356,8 +1198,37 @@ void ChatRootView::compressCurrentChat() autosave(); - m_chatCompressor->startCompression( - m_recentFilePath, m_controller->session()->history()); + m_chatCompressor->startCompression(m_recentFilePath, m_controller->session()->history()); +} + +QStringList ChatRootView::availableConfigurations() const +{ + return m_configurationController->availableConfigurations(); +} + +QString ChatRootView::currentConfiguration() const +{ + return m_configurationController->currentConfiguration(); +} + +QString ChatRootView::baseSystemPrompt() const +{ + return Settings::chatAssistantSettings().systemPrompt(); +} + +void ChatRootView::compressCurrentChat() +{ + m_coordinator->shrinkContext(); +} + +bool ChatRootView::canShrinkContext() const +{ + return m_coordinator->canShrinkContext(); +} + +QString ChatRootView::shrinkContextTooltip() const +{ + return m_coordinator->shrinkContextTooltip(); } void ChatRootView::cancelCompression() diff --git a/sources/ChatView/ChatRootView.hpp b/sources/ChatView/ChatRootView.hpp index c186789..c842a93 100644 --- a/sources/ChatView/ChatRootView.hpp +++ b/sources/ChatView/ChatRootView.hpp @@ -9,10 +9,11 @@ #include #include "ChatController.hpp" -#include "ChatFileManager.hpp" +#include "AttachmentStaging.hpp" #include "ChatModel.hpp" +#include "ConversationCoordinator.hpp" #include "templates/PromptProviderChat.hpp" -#include +#include namespace QodeAssist::Skills { class SkillsManager; @@ -21,21 +22,21 @@ class SkillsManager; namespace QodeAssist::Chat { class ChatCompressor; -class AgentRoleController; class ChatConfigurationController; class FileEditController; class InputTokenCounter; -class ChatHistoryStore; +class ChatFileStore; class SessionFileRegistry; -class ChatRootView : public QQuickItem +class ChatRootView : public QQuickItem, + private IAgentCatalogPort, + private ICompressionPort, + private ISendPort { Q_OBJECT Q_PROPERTY(QodeAssist::Chat::ChatModel *chatModel READ chatModel NOTIFY chatModelChanged FINAL) Q_PROPERTY(QString currentTemplate READ currentTemplate NOTIFY currentTemplateChanged FINAL) - Q_PROPERTY(bool isSyncOpenFiles READ isSyncOpenFiles NOTIFY isSyncOpenFilesChanged FINAL) Q_PROPERTY(QStringList attachmentFiles READ attachmentFiles NOTIFY attachmentFilesChanged FINAL) - Q_PROPERTY(QStringList linkedFiles READ linkedFiles NOTIFY linkedFilesChanged FINAL) Q_PROPERTY(int inputTokensCount READ inputTokensCount NOTIFY inputTokensCountChanged FINAL) Q_PROPERTY(QString chatFileName READ chatFileName NOTIFY chatFileNameChanged FINAL) Q_PROPERTY(QString textFontFamily READ textFontFamily NOTIFY textFamilyChanged FINAL) @@ -46,24 +47,25 @@ class ChatRootView : public QQuickItem Q_PROPERTY(bool isRequestInProgress READ isRequestInProgress NOTIFY isRequestInProgressChanged FINAL) Q_PROPERTY(QString lastErrorMessage READ lastErrorMessage NOTIFY lastErrorMessageChanged FINAL) Q_PROPERTY(QString lastInfoMessage READ lastInfoMessage NOTIFY lastInfoMessageChanged FINAL) - Q_PROPERTY(QVariantList activeRules READ activeRules NOTIFY activeRulesChanged FINAL) - Q_PROPERTY(int activeRulesCount READ activeRulesCount NOTIFY activeRulesCountChanged FINAL) - Q_PROPERTY(bool useTools READ useTools WRITE setUseTools NOTIFY useToolsChanged FINAL) - Q_PROPERTY(bool useThinking READ useThinking WRITE setUseThinking NOTIFY useThinkingChanged FINAL) Q_PROPERTY(QString sendShortcutText READ sendShortcutText NOTIFY sendShortcutTextChanged FINAL) Q_PROPERTY(int currentMessageTotalEdits READ currentMessageTotalEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessageAppliedEdits READ currentMessageAppliedEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessagePendingEdits READ currentMessagePendingEdits NOTIFY currentMessageEditsStatsChanged FINAL) Q_PROPERTY(int currentMessageRejectedEdits READ currentMessageRejectedEdits NOTIFY currentMessageEditsStatsChanged FINAL) - Q_PROPERTY(bool isThinkingSupport READ isThinkingSupport NOTIFY isThinkingSupportChanged FINAL) + Q_PROPERTY(bool isAgentBound READ isAgentBound NOTIFY isAgentBoundChanged FINAL) + Q_PROPERTY(QString agentSessionIssue READ agentSessionIssue NOTIFY agentSessionIssueChanged FINAL) + Q_PROPERTY(bool canStartNewAgentSession READ canStartNewAgentSession NOTIFY + agentSessionIssueChanged FINAL) + Q_PROPERTY(bool canHandOverSummary READ canHandOverSummary NOTIFY agentSessionIssueChanged FINAL) + Q_PROPERTY(QString summaryHandoverTooltip READ summaryHandoverTooltip NOTIFY + agentSessionIssueChanged FINAL) + Q_PROPERTY(bool canShrinkContext READ canShrinkContext NOTIFY shrinkContextStateChanged FINAL) + Q_PROPERTY(QString shrinkContextTooltip READ shrinkContextTooltip NOTIFY + shrinkContextStateChanged FINAL) Q_PROPERTY(QStringList availableConfigurations READ availableConfigurations NOTIFY availableConfigurationsChanged FINAL) Q_PROPERTY(QString currentConfiguration READ currentConfiguration NOTIFY currentConfigurationChanged FINAL) - Q_PROPERTY(QStringList availableAgentRoles READ availableAgentRoles NOTIFY availableAgentRolesChanged FINAL) - Q_PROPERTY(QString currentAgentRole READ currentAgentRole NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(QString baseSystemPrompt READ baseSystemPrompt NOTIFY baseSystemPromptChanged FINAL) - Q_PROPERTY(QString currentAgentRoleDescription READ currentAgentRoleDescription NOTIFY currentAgentRoleChanged FINAL) - Q_PROPERTY(QString currentAgentRoleSystemPrompt READ currentAgentRoleSystemPrompt NOTIFY currentAgentRoleChanged FINAL) Q_PROPERTY(bool isCompressing READ isCompressing NOTIFY isCompressingChanged FINAL) Q_PROPERTY(bool isInEditor READ isInEditor NOTIFY isInEditorChanged FINAL) Q_PROPERTY(QString chatTitle READ chatTitle NOTIFY chatTitleChanged FINAL) @@ -88,23 +90,17 @@ public: QString getAutosaveFilePath(const QString &firstMessage, const QStringList &attachments) const; QStringList attachmentFiles() const; - QStringList linkedFiles() const; Q_INVOKABLE void showAttachFilesDialog(); Q_INVOKABLE void addFilesToAttachList(const QStringList &filePaths); Q_INVOKABLE void removeFileFromAttachList(int index); - Q_INVOKABLE void showLinkFilesDialog(); - Q_INVOKABLE void addFilesToLinkList(const QStringList &filePaths); - Q_INVOKABLE void removeFileFromLinkList(int index); Q_INVOKABLE QStringList convertUrlsToLocalPaths(const QVariantList &urls) const; Q_INVOKABLE void showAddImageDialog(); Q_INVOKABLE bool isImageFile(const QString &filePath) const; Q_INVOKABLE void calculateMessageTokensCount(const QString &message); Q_INVOKABLE bool isSendShortcut(int key, int modifiers) const; QString sendShortcutText() const; - Q_INVOKABLE void setIsSyncOpenFiles(bool state); Q_INVOKABLE void openChatHistoryFolder(); - Q_INVOKABLE void openRulesFolder(); Q_INVOKABLE void openSettings(); Q_INVOKABLE void openFileInEditor(const QString &filePath); @@ -117,16 +113,9 @@ public: Q_INVOKABLE void updateInputTokensCount(); int inputTokensCount() const; - bool isSyncOpenFiles() const; - - void onEditorAboutToClose(Core::IEditor *editor); - void onAppendLinkFileFromEditor(Core::IEditor *editor); - void onEditorCreated(Core::IEditor *editor, const Utils::FilePath &filePath); - QString chatFileName() const; Q_INVOKABLE QString chatFilePath() const; void setRecentFilePath(const QString &filePath); - bool shouldIgnoreFileForAttach(const Utils::FilePath &filePath); QString textFontFamily() const; QString codeFontFamily() const; @@ -139,18 +128,10 @@ public: void setRequestProgressStatus(bool state); QString lastErrorMessage() const; - - QVariantList activeRules() const; - int activeRulesCount() const; - Q_INVOKABLE QString getRuleContent(int index); - Q_INVOKABLE void refreshRules(); - Q_INVOKABLE QVariantList searchSkills(const QString &query) const; + Q_INVOKABLE QVariantList searchSlashCommands(const QString &query) const; - bool useTools() const; - void setUseTools(bool enabled); - bool useThinking() const; - void setUseThinking(bool enabled); + Q_INVOKABLE void respondToPermission(const QString &requestId, const QString &optionId); Q_INVOKABLE void applyFileEdit(const QString &editId); Q_INVOKABLE void rejectFileEdit(const QString &editId); @@ -163,21 +144,16 @@ public: Q_INVOKABLE void loadAvailableConfigurations(); Q_INVOKABLE void applyConfiguration(const QString &configName); + Q_INVOKABLE void confirmChatTargetSwitch(); + Q_INVOKABLE void cancelChatTargetSwitch(); QStringList availableConfigurations() const; QString currentConfiguration() const; Q_INVOKABLE void compressCurrentChat(); Q_INVOKABLE void cancelCompression(); - Q_INVOKABLE void loadAvailableAgentRoles(); - Q_INVOKABLE void applyAgentRole(const QString &roleId); - Q_INVOKABLE void openAgentRolesSettings(); - QStringList availableAgentRoles() const; - QString currentAgentRole() const; QString baseSystemPrompt() const; - QString currentAgentRoleDescription() const; - QString currentAgentRoleSystemPrompt() const; - + int currentMessageTotalEdits() const; int currentMessageAppliedEdits() const; int currentMessagePendingEdits() const; @@ -185,8 +161,16 @@ public: QString lastInfoMessage() const; - bool isThinkingSupport() const; - + bool isAgentBound() const; + QString agentSessionIssue() const; + bool canStartNewAgentSession() const; + bool canHandOverSummary() const; + QString summaryHandoverTooltip() const; + bool canShrinkContext() const; + QString shrinkContextTooltip() const; + Q_INVOKABLE void startNewAgentSession(); + Q_INVOKABLE void startNewAgentSessionWithSummary(); + bool isCompressing() const; bool isInEditor() const; @@ -201,7 +185,6 @@ public slots: void copyToClipboard(const QString &text); void cancelRequest(); void clearAttachmentFiles(); - void clearLinkedFiles(); void clearMessages(); void resetChatToMessage(int index); @@ -209,9 +192,7 @@ signals: void chatModelChanged(); void currentTemplateChanged(); void attachmentFilesChanged(); - void linkedFilesChanged(); void inputTokensCountChanged(); - void isSyncOpenFilesChanged(); void chatFileNameChanged(); void textFamilyChanged(); void codeFamilyChanged(); @@ -224,19 +205,17 @@ signals: void lastErrorMessageChanged(); void lastInfoMessageChanged(); void sendShortcutTextChanged(); - void activeRulesChanged(); - void activeRulesCountChanged(); - void useToolsChanged(); - void useThinkingChanged(); void currentMessageEditsStatsChanged(); - void isThinkingSupportChanged(); + void isAgentBoundChanged(); + void agentSessionIssueChanged(); + void shrinkContextStateChanged(); + void slashCommandsChanged(); void availableConfigurationsChanged(); void currentConfigurationChanged(); + void chatTargetSwitchNeedsNewChat(const QString &targetName); - void availableAgentRolesChanged(); - void currentAgentRoleChanged(); void baseSystemPromptChanged(); void isCompressingChanged(); @@ -246,69 +225,59 @@ signals: void isInEditorChanged(); void chatTitleChanged(); - void openFilesChanged(); - void closeHostRequested(); +protected: + void componentComplete() override; + private: + QVariantList searchSkills(const QString &query) const; + QVariantList searchAgentCommands(const QString &query) const; QString computeChatTitle() const; void triggerOpenChatCommand(Utils::Id commandId); void handOffSession(); - bool deferSendForAutoCompress( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles, - bool useTools, - bool useThinking); - void dispatchSend( - const QString &message, - const QStringList &attachments, - const QStringList &linkedFiles, - bool useTools, - bool useThinking); bool hasImageAttachments(const QStringList &attachments) const; + std::optional agentById(const QString &agentId) const override; + QString compressionConfigurationIssue() const override; + bool isCompressionRunning() const override; + void startTranscriptSummary() override; + void startCompression() override; + bool autoCompressEnabled() const override; + int autoCompressThreshold() const override; + int estimatedNextTokens() const override; + bool prepareChatFileForCompression( + const QString &message, const QStringList &attachments) override; + void dispatch(const QString &message, const QStringList &attachments) override; + SessionFileRegistry *sessionFileRegistry() const; Skills::SkillsManager *skillsManager() const; ChatModel *m_chatModel; Templates::PromptProviderChat m_promptProvider; ChatController *m_controller; - ChatFileManager *m_fileManager; + AttachmentStaging *m_attachmentStaging; QString m_currentTemplate; QString m_recentFilePath; QStringList m_attachmentFiles; - QStringList m_linkedFiles; - struct PendingSend { - QString message; - QStringList attachments; - QStringList linkedFiles; - bool useTools = false; - bool useThinking = false; - bool active = false; - }; - PendingSend m_pendingSend; - bool m_isSyncOpenFiles; bool m_isInEditor = false; mutable QString m_cachedChatTitle; - QList m_currentEditors; bool m_isRequestInProgress; QString m_lastErrorMessage; - QVariantList m_activeRules; - + QString m_lastInfoMessage; ChatCompressor *m_chatCompressor; - AgentRoleController *m_agentRoleController; ChatConfigurationController *m_configurationController; FileEditController *m_fileEditController; InputTokenCounter *m_tokenCounter; - ChatHistoryStore *m_historyStore; + ChatFileStore *m_historyStore; mutable QPointer m_sessionFileRegistry; mutable bool m_sessionFileRegistryResolved = false; mutable QPointer m_skillsManager; mutable bool m_skillsManagerResolved = false; + ConversationCoordinator *m_coordinator = nullptr; }; } // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatSerializer.cpp b/sources/ChatView/ChatSerializer.cpp deleted file mode 100644 index 86281da..0000000 --- a/sources/ChatView/ChatSerializer.cpp +++ /dev/null @@ -1,169 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "ChatSerializer.hpp" -#include "Logger.hpp" - -#include -#include -#include -#include -#include -#include -#include - -#include "session/HistorySerializer.hpp" - -namespace QodeAssist::Chat { - -SerializationResult ChatSerializer::saveToFile( - const Session::ConversationHistory &history, const QString &filePath) -{ - if (!ensureDirectoryExists(filePath)) { - return {false, "Failed to create directory structure"}; - } - - const QJsonObject root = Session::HistorySerializer::toJson(history); - - QSaveFile file(filePath); - if (!file.open(QIODevice::WriteOnly)) { - return {false, QString("Failed to open file for writing: %1").arg(filePath)}; - } - - if (file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)) == -1) { - return {false, QString("Failed to write to file: %1").arg(file.errorString())}; - } - - if (!file.commit()) { - return {false, QString("Failed to save file: %1").arg(file.errorString())}; - } - - return {true, QString()}; -} - -SerializationResult ChatSerializer::loadFromFile( - Session::ConversationHistory &history, const QString &filePath) -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) { - return {false, QString("Failed to open file for reading: %1").arg(filePath)}; - } - - QJsonParseError error; - QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); - if (error.error != QJsonParseError::NoError) { - return {false, QString("JSON parse error: %1").arg(error.errorString())}; - } - - const QJsonObject root = doc.object(); - const QString version = root["version"].toString(); - - if (!Session::HistorySerializer::isSupportedVersion(version)) { - return {false, QString("Unsupported version: %1").arg(version)}; - } - - int droppedBlocks = 0; - const auto loaded = Session::HistorySerializer::fromJson(root, &droppedBlocks); - if (!loaded) { - return {false, QString("Failed to read chat history from: %1").arg(filePath)}; - } - - if (version != Session::HistorySerializer::currentVersion()) { - LOG_MESSAGE(QString("Converted chat from format %1 to %2") - .arg(version, Session::HistorySerializer::currentVersion())); - } - - history = *loaded; - - if (droppedBlocks > 0) { - const QString warning - = QString( - "%1 message part(s) in this chat could not be read and will be lost if " - "the chat is saved again") - .arg(droppedBlocks); - LOG_MESSAGE(QString("%1: %2").arg(filePath, warning)); - return {true, QString(), warning}; - } - - return {true, QString(), QString()}; -} - -bool ChatSerializer::ensureDirectoryExists(const QString &filePath) -{ - QFileInfo fileInfo(filePath); - QDir dir = fileInfo.dir(); - return dir.exists() || dir.mkpath("."); -} - -QString ChatSerializer::getChatContentFolder(const QString &chatFilePath) -{ - QFileInfo fileInfo(chatFilePath); - QString baseName = fileInfo.completeBaseName(); - QString dirPath = fileInfo.absolutePath(); - return QDir(dirPath).filePath(baseName + "_content"); -} - -bool ChatSerializer::saveContentToStorage( - const QString &chatFilePath, - const QString &fileName, - const QString &base64Data, - QString &storedPath) -{ - QString contentFolder = getChatContentFolder(chatFilePath); - QDir dir; - if (!dir.exists(contentFolder)) { - if (!dir.mkpath(contentFolder)) { - LOG_MESSAGE(QString("Failed to create content folder: %1").arg(contentFolder)); - return false; - } - } - - QFileInfo originalFileInfo(fileName); - QString extension = originalFileInfo.suffix(); - QString baseName = originalFileInfo.completeBaseName(); - QString uniqueName = QString("%1_%2.%3") - .arg(baseName) - .arg(QUuid::createUuid().toString(QUuid::WithoutBraces).left(8)) - .arg(extension); - - QString fullPath = QDir(contentFolder).filePath(uniqueName); - - QByteArray contentData = QByteArray::fromBase64(base64Data.toUtf8()); - QFile file(fullPath); - if (!file.open(QIODevice::WriteOnly)) { - LOG_MESSAGE(QString("Failed to open file for writing: %1").arg(fullPath)); - return false; - } - - if (file.write(contentData) == -1) { - LOG_MESSAGE(QString("Failed to write content data: %1").arg(file.errorString())); - return false; - } - - file.close(); - - storedPath = uniqueName; - LOG_MESSAGE(QString("Saved content: %1 to %2").arg(fileName, fullPath)); - - return true; -} - -QString ChatSerializer::loadContentFromStorage(const QString &chatFilePath, const QString &storedPath) -{ - QString contentFolder = getChatContentFolder(chatFilePath); - QString fullPath = QDir(contentFolder).filePath(storedPath); - - QFile file(fullPath); - if (!file.open(QIODevice::ReadOnly)) { - LOG_MESSAGE(QString("Failed to open content file: %1").arg(fullPath)); - return QString(); - } - - QByteArray contentData = file.readAll(); - file.close(); - - return contentData.toBase64(); -} - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ChatSerializer.hpp b/sources/ChatView/ChatSerializer.hpp deleted file mode 100644 index 6d95373..0000000 --- a/sources/ChatView/ChatSerializer.hpp +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "session/ConversationHistory.hpp" - -namespace QodeAssist::Chat { - -struct SerializationResult -{ - bool success{false}; - QString errorMessage; - QString warningMessage; -}; - -class ChatSerializer -{ -public: - static SerializationResult saveToFile( - const Session::ConversationHistory &history, const QString &filePath); - static SerializationResult loadFromFile( - Session::ConversationHistory &history, const QString &filePath); - - // Content management (images and text files) - static QString getChatContentFolder(const QString &chatFilePath); - static bool saveContentToStorage(const QString &chatFilePath, - const QString &fileName, - const QString &base64Data, - QString &storedPath); - static QString loadContentFromStorage(const QString &chatFilePath, const QString &storedPath); - -private: - static bool ensureDirectoryExists(const QString &filePath); -}; - -} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ConversationCoordinator.cpp b/sources/ChatView/ConversationCoordinator.cpp new file mode 100644 index 0000000..3c89e1a --- /dev/null +++ b/sources/ChatView/ConversationCoordinator.cpp @@ -0,0 +1,373 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ConversationCoordinator.hpp" + +#include "logger/Logger.hpp" + +namespace QodeAssist::Chat { + +ConversationCoordinator::ConversationCoordinator(const Ports &ports, QObject *parent) + : QObject(parent) + , m_ports(ports) +{} + +void ConversationCoordinator::requestSend(const QString &message, const QStringList &attachments) +{ + if (refuseWhileReadOnly()) + return; + + if (!m_ports.ops->boundAgentId().isEmpty() + && m_ports.compression->isCompressionRunning()) { + emit errorSurfaced( + tr("A handover summary is being prepared; wait for it to finish before sending")); + return; + } + + if (deferSendForAutoCompress(message, attachments)) + return; + + m_ports.send->dispatch(message, attachments); +} + +bool ConversationCoordinator::refuseWhileReadOnly() +{ + if (m_sessionIssue.isEmpty()) + return false; + + emit errorSurfaced(m_sessionIssue); + return true; +} + +bool ConversationCoordinator::hasDeferredSend() const +{ + return m_deferredSend.active; +} + +bool ConversationCoordinator::deferSendForAutoCompress( + const QString &message, const QStringList &attachments) +{ + if (!m_ports.ops->boundAgentId().isEmpty()) + return false; + + if (!m_ports.send->autoCompressEnabled()) + return false; + + const int threshold = m_ports.send->autoCompressThreshold(); + const int inputTokens = m_ports.send->estimatedNextTokens(); + if (inputTokens < threshold) + return false; + + if (!m_ports.send->prepareChatFileForCompression(message, attachments)) + return false; + + if (m_ports.compression->isCompressionRunning() || m_deferredSend.active) + return false; + + LOG_MESSAGE(QString("Auto-compress preempt: estimated next=%1 ≥ threshold=%2; deferring send") + .arg(inputTokens) + .arg(threshold)); + + m_deferredSend = {message, attachments, true}; + m_ports.compression->startCompression(); + return true; +} + +void ConversationCoordinator::compressionSettled() +{ + if (!m_deferredSend.active) + return; + + const DeferredSend deferred = m_deferredSend; + m_deferredSend = {}; + + if (refuseWhileReadOnly()) + return; + + m_ports.send->dispatch(deferred.message, deferred.attachments); +} + +void ConversationCoordinator::chooseAgent(const Acp::AgentDefinition &agent) +{ + if (m_ports.ops->boundAgentId() == agent.id) + return; + + if (m_ports.ops->conversationStarted()) { + m_pendingAgent = agent; + m_pendingLlmSwitch = false; + emit switchConfirmationNeeded(agent.name); + return; + } + + bindAgentNow(agent); +} + +void ConversationCoordinator::chooseLlm() +{ + if (m_ports.ops->boundAgentId().isEmpty()) + return; + + if (m_ports.ops->conversationStarted()) { + m_pendingAgent.reset(); + m_pendingLlmSwitch = true; + emit switchConfirmationNeeded(tr("direct LLM chat")); + return; + } + + bindLlmNow(); +} + +void ConversationCoordinator::confirmSwitch() +{ + const auto agent = m_pendingAgent; + const bool toLlm = m_pendingLlmSwitch; + + m_pendingAgent.reset(); + m_pendingLlmSwitch = false; + + if (!agent && !toLlm) + return; + + m_ports.ops->clearConversation(); + + if (agent) + bindAgentNow(*agent); + else + bindLlmNow(); +} + +void ConversationCoordinator::cancelSwitch() +{ + m_pendingAgent.reset(); + m_pendingLlmSwitch = false; + emit switchCancelled(); +} + +bool ConversationCoordinator::switchPending() const +{ + return m_pendingAgent.has_value() || m_pendingLlmSwitch; +} + +void ConversationCoordinator::bindAgentNow(const Acp::AgentDefinition &agent) +{ + m_ports.ops->bindAgent(agent); + emit boundToAgent(agent); +} + +void ConversationCoordinator::bindLlmNow() +{ + m_ports.ops->bindLlm(); + emit boundToLlm(); +} + +void ConversationCoordinator::restoreAgentBinding(const Acp::AgentBinding &binding) +{ + setSessionIssue(QString(), false); + m_quarantinedBinding = {}; + m_ports.ops->releaseAgentSession(); + + if (binding.isEmpty()) { + bindLlmNow(); + return; + } + + const auto agent = m_ports.catalog->agentById(binding.agentId); + if (!agent || !agent->isLaunchable()) { + m_quarantinedBinding = binding; + bindLlmNow(); + setSessionIssue( + tr("This chat was held with the agent \"%1\", which is not available any more. " + "The transcript is read-only, and the agent it names is kept so the chat can be " + "continued once that agent is installed again.") + .arg(binding.displayId()), + false); + return; + } + + bindAgentNow(*agent); + + if (!binding.sessionId.isEmpty()) { + m_ports.ops->resumeAgentSession(binding.sessionId); + return; + } + + if (m_ports.ops->conversationStarted()) { + setSessionIssue( + tr("This chat records the agent \"%1\" but not a session to reopen, so the transcript " + "is read-only. Start a new session to keep working with this agent — it will not " + "have the context above.") + .arg(binding.displayId()), + true); + } +} + +Acp::AgentBinding ConversationCoordinator::bindingForSave() const +{ + return m_quarantinedBinding.isEmpty() ? m_ports.ops->agentBinding() : m_quarantinedBinding; +} + +QString ConversationCoordinator::sessionIssue() const +{ + return m_sessionIssue; +} + +bool ConversationCoordinator::readOnly() const +{ + return !m_sessionIssue.isEmpty(); +} + +bool ConversationCoordinator::canStartFreshSession() const +{ + return m_sessionRecoverable; +} + +bool ConversationCoordinator::canHandOverSummary() const +{ + return m_sessionRecoverable && m_ports.compression->compressionConfigurationIssue().isEmpty() + && !m_ports.ops->transcriptEmpty(); +} + +QString ConversationCoordinator::summaryHandoverTooltip() const +{ + if (!m_sessionRecoverable) + return {}; + + if (m_ports.ops->transcriptEmpty()) + return tr("There is nothing to summarise yet."); + + const QString issue = m_ports.compression->compressionConfigurationIssue(); + if (!issue.isEmpty()) { + return tr("A summary cannot be produced because %1. Start a new session without one, or " + "assign the chat feature in the settings.") + .arg(issue); + } + + return tr("Summarise this transcript and give it to the new session as context."); +} + +void ConversationCoordinator::startFreshSession() +{ + if (!m_sessionRecoverable) + return; + + m_ports.ops->startFreshAgentSession(); + setSessionIssue(QString(), false); +} + +void ConversationCoordinator::handOverSummary() +{ + if (!canHandOverSummary()) + return; + + if (m_ports.compression->isCompressionRunning()) { + emit errorSurfaced(tr("A summary is already being prepared")); + return; + } + + m_ports.compression->startTranscriptSummary(); +} + +void ConversationCoordinator::shrinkContext() +{ + if (refuseWhileReadOnly()) + return; + + if (!canShrinkContext()) { + emit errorSurfaced(shrinkContextTooltip()); + return; + } + + if (m_ports.ops->boundAgentId().isEmpty()) { + m_ports.compression->startCompression(); + return; + } + + if (m_ports.compression->isCompressionRunning()) { + emit errorSurfaced(tr("A summary is already being prepared")); + return; + } + + m_ports.compression->startTranscriptSummary(); +} + +bool ConversationCoordinator::canShrinkContext() const +{ + if (readOnly()) + return false; + + if (!m_ports.compression->compressionConfigurationIssue().isEmpty()) + return false; + + if (m_ports.ops->boundAgentId().isEmpty()) + return true; + + return !m_ports.ops->transcriptEmpty(); +} + +QString ConversationCoordinator::shrinkContextTooltip() const +{ + const QString issue = m_ports.compression->compressionConfigurationIssue(); + if (!issue.isEmpty()) { + return tr("Unavailable because %1. Assign the chat feature in the settings.").arg(issue); + } + + if (m_ports.ops->boundAgentId().isEmpty()) + return tr("Compress chat (create summarized copy using LLM)"); + + if (m_ports.ops->transcriptEmpty()) + return tr("There is nothing to summarise yet."); + + return tr("Summarise this conversation and hand it to a fresh agent session as context."); +} + +void ConversationCoordinator::summaryProduced(const QString &summary) +{ + m_ports.ops->startFreshAgentSession(summary); + setSessionIssue(QString(), false); +} + +QString ConversationCoordinator::agentTitle() const +{ + return m_agentTitle; +} + +void ConversationCoordinator::titleSuggested(const QString &title) +{ + if (m_agentTitle == title) + return; + + m_agentTitle = title; + emit agentTitleChanged(); +} + +void ConversationCoordinator::agentSessionUnavailable(const QString &reason) +{ + LOG_MESSAGE(QString("Agent session could not be reopened: %1").arg(reason)); + setSessionIssue( + tr("The previous session with this agent could not be reopened, so the transcript " + "is read-only. Start a new session to keep working with this agent — it will " + "not have the context above."), + true); +} + +void ConversationCoordinator::conversationReset() +{ + if (!m_agentTitle.isEmpty()) { + m_agentTitle.clear(); + emit agentTitleChanged(); + } + setSessionIssue(QString(), false); +} + +void ConversationCoordinator::setSessionIssue(const QString &issue, bool recoverable) +{ + if (m_sessionIssue == issue && m_sessionRecoverable == recoverable) + return; + + m_sessionIssue = issue; + m_sessionRecoverable = recoverable; + emit sessionIssueChanged(); +} + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ConversationCoordinator.hpp b/sources/ChatView/ConversationCoordinator.hpp new file mode 100644 index 0000000..dffd75f --- /dev/null +++ b/sources/ChatView/ConversationCoordinator.hpp @@ -0,0 +1,100 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include +#include +#include + +#include "ConversationPorts.hpp" + +namespace QodeAssist::Chat { + +class ConversationCoordinator : public QObject +{ + Q_OBJECT + +public: + struct Ports + { + IConversationPort *ops = nullptr; + IAgentCatalogPort *catalog = nullptr; + ICompressionPort *compression = nullptr; + ISendPort *send = nullptr; + }; + + explicit ConversationCoordinator(const Ports &ports, QObject *parent = nullptr); + + void requestSend(const QString &message, const QStringList &attachments); + bool refuseWhileReadOnly(); + bool hasDeferredSend() const; + + void chooseAgent(const Acp::AgentDefinition &agent); + void chooseLlm(); + void confirmSwitch(); + void cancelSwitch(); + bool switchPending() const; + + void restoreAgentBinding(const Acp::AgentBinding &binding); + Acp::AgentBinding bindingForSave() const; + + QString sessionIssue() const; + bool readOnly() const; + bool canStartFreshSession() const; + + bool canHandOverSummary() const; + QString summaryHandoverTooltip() const; + void startFreshSession(); + void handOverSummary(); + + void shrinkContext(); + bool canShrinkContext() const; + QString shrinkContextTooltip() const; + + QString agentTitle() const; + + void conversationReset(); + +public slots: + void titleSuggested(const QString &title); + void agentSessionUnavailable(const QString &reason); + void summaryProduced(const QString &summary); + void compressionSettled(); + +signals: + void sessionIssueChanged(); + void agentTitleChanged(); + void boundToAgent(const QodeAssist::Acp::AgentDefinition &agent); + void boundToLlm(); + void switchConfirmationNeeded(const QString &targetName); + void switchCancelled(); + void errorSurfaced(const QString &message); + +private: + struct DeferredSend + { + QString message; + QStringList attachments; + bool active = false; + }; + + void setSessionIssue(const QString &issue, bool recoverable); + bool deferSendForAutoCompress(const QString &message, const QStringList &attachments); + void bindAgentNow(const Acp::AgentDefinition &agent); + void bindLlmNow(); + + Ports m_ports; + std::optional m_pendingAgent; + bool m_pendingLlmSwitch = false; + DeferredSend m_deferredSend; + QString m_agentTitle; + QString m_sessionIssue; + bool m_sessionRecoverable = false; + Acp::AgentBinding m_quarantinedBinding; +}; + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/ConversationPorts.hpp b/sources/ChatView/ConversationPorts.hpp new file mode 100644 index 0000000..7cc6cfc --- /dev/null +++ b/sources/ChatView/ConversationPorts.hpp @@ -0,0 +1,70 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include +#include + +#include "acp/AgentBinding.hpp" +#include "acp/AgentDefinition.hpp" + +namespace QodeAssist::Chat { + +class IConversationPort +{ +public: + virtual ~IConversationPort() = default; + + virtual QString boundAgentId() const = 0; + virtual bool conversationStarted() const = 0; + virtual bool transcriptEmpty() const = 0; + virtual Acp::AgentBinding agentBinding() const = 0; + + virtual void bindAgent(const Acp::AgentDefinition &agent) = 0; + virtual void bindLlm() = 0; + virtual void clearConversation() = 0; + + virtual void resumeAgentSession(const QString &sessionId) = 0; + virtual void startFreshAgentSession() = 0; + virtual void startFreshAgentSession(const QString &handoverSummary) = 0; + virtual void releaseAgentSession() = 0; +}; + +class IAgentCatalogPort +{ +public: + virtual ~IAgentCatalogPort() = default; + + virtual std::optional agentById(const QString &agentId) const = 0; +}; + +class ICompressionPort +{ +public: + virtual ~ICompressionPort() = default; + + virtual QString compressionConfigurationIssue() const = 0; + virtual bool isCompressionRunning() const = 0; + virtual void startTranscriptSummary() = 0; + virtual void startCompression() = 0; +}; + +class ISendPort +{ +public: + virtual ~ISendPort() = default; + + virtual bool autoCompressEnabled() const = 0; + virtual int autoCompressThreshold() const = 0; + virtual int estimatedNextTokens() const = 0; + virtual bool prepareChatFileForCompression( + const QString &message, const QStringList &attachments) + = 0; + virtual void dispatch(const QString &message, const QStringList &attachments) = 0; +}; + +} // namespace QodeAssist::Chat diff --git a/sources/ChatView/FileEditController.cpp b/sources/ChatView/FileEditController.cpp index 208a7bf..2e037fe 100644 --- a/sources/ChatView/FileEditController.cpp +++ b/sources/ChatView/FileEditController.cpp @@ -11,27 +11,27 @@ #include #include "Logger.hpp" -#include "context/ChangesManager.h" +#include "context/FileEditManager.hpp" namespace QodeAssist::Chat { FileEditController::FileEditController(QObject *parent) : QObject(parent) { - auto &changes = Context::ChangesManager::instance(); - connect(&changes, &Context::ChangesManager::fileEditAdded, this, [this](const QString &) { + auto &changes = Context::FileEditManager::instance(); + connect(&changes, &Context::FileEditManager::fileEditAdded, this, [this](const QString &) { updateStats(); }); - connect(&changes, &Context::ChangesManager::fileEditApplied, this, [this](const QString &) { + connect(&changes, &Context::FileEditManager::fileEditApplied, this, [this](const QString &) { updateStats(); }); - connect(&changes, &Context::ChangesManager::fileEditRejected, this, [this](const QString &) { + connect(&changes, &Context::FileEditManager::fileEditRejected, this, [this](const QString &) { updateStats(); }); - connect(&changes, &Context::ChangesManager::fileEditUndone, this, [this](const QString &) { + connect(&changes, &Context::FileEditManager::fileEditUndone, this, [this](const QString &) { updateStats(); }); - connect(&changes, &Context::ChangesManager::fileEditArchived, this, [this](const QString &) { + connect(&changes, &Context::FileEditManager::fileEditArchived, this, [this](const QString &) { updateStats(); }); } @@ -76,11 +76,11 @@ int FileEditController::rejectedEdits() const void FileEditController::applyFileEdit(const QString &editId) { LOG_MESSAGE(QString("Applying file edit: %1").arg(editId)); - if (Context::ChangesManager::instance().applyFileEdit(editId)) { + if (Context::FileEditManager::instance().applyFileEdit(editId)) { emit infoMessage(QString("File edit applied successfully")); updateStats(); } else { - auto edit = Context::ChangesManager::instance().getFileEdit(editId); + auto edit = Context::FileEditManager::instance().getFileEdit(editId); emit errorOccurred( edit.statusMessage.isEmpty() ? QString("Failed to apply file edit") @@ -91,11 +91,11 @@ void FileEditController::applyFileEdit(const QString &editId) void FileEditController::rejectFileEdit(const QString &editId) { LOG_MESSAGE(QString("Rejecting file edit: %1").arg(editId)); - if (Context::ChangesManager::instance().rejectFileEdit(editId)) { + if (Context::FileEditManager::instance().rejectFileEdit(editId)) { emit infoMessage(QString("File edit rejected")); updateStats(); } else { - auto edit = Context::ChangesManager::instance().getFileEdit(editId); + auto edit = Context::FileEditManager::instance().getFileEdit(editId); emit errorOccurred( edit.statusMessage.isEmpty() ? QString("Failed to reject file edit") @@ -106,11 +106,11 @@ void FileEditController::rejectFileEdit(const QString &editId) void FileEditController::undoFileEdit(const QString &editId) { LOG_MESSAGE(QString("Undoing file edit: %1").arg(editId)); - if (Context::ChangesManager::instance().undoFileEdit(editId)) { + if (Context::FileEditManager::instance().undoFileEdit(editId)) { emit infoMessage(QString("File edit undone successfully")); updateStats(); } else { - auto edit = Context::ChangesManager::instance().getFileEdit(editId); + auto edit = Context::FileEditManager::instance().getFileEdit(editId); emit errorOccurred( edit.statusMessage.isEmpty() ? QString("Failed to undo file edit") @@ -122,7 +122,7 @@ void FileEditController::openFileEditInEditor(const QString &editId) { LOG_MESSAGE(QString("Opening file edit in editor: %1").arg(editId)); - auto edit = Context::ChangesManager::instance().getFileEdit(editId); + auto edit = Context::FileEditManager::instance().getFileEdit(editId); if (edit.editId.isEmpty()) { emit errorOccurred(QString("File edit not found: %1").arg(editId)); return; @@ -143,7 +143,7 @@ void FileEditController::openFileEditInEditor(const QString &editId) QString currentContent = doc->toPlainText(); int position = -1; - if (edit.status == Context::ChangesManager::Applied && !edit.newContent.isEmpty()) { + if (edit.status == Context::FileEditManager::Applied && !edit.newContent.isEmpty()) { position = currentContent.indexOf(edit.newContent); } else if (!edit.oldContent.isEmpty()) { position = currentContent.indexOf(edit.oldContent); @@ -171,7 +171,7 @@ void FileEditController::applyAllForCurrentMessage() LOG_MESSAGE(QString("Applying all file edits for message: %1").arg(m_currentRequestId)); QString errorMsg; - bool success = Context::ChangesManager::instance() + bool success = Context::FileEditManager::instance() .reapplyAllEditsForRequest(m_currentRequestId, &errorMsg); if (success) { @@ -196,7 +196,7 @@ void FileEditController::undoAllForCurrentMessage() LOG_MESSAGE(QString("Undoing all file edits for message: %1").arg(m_currentRequestId)); QString errorMsg; - bool success = Context::ChangesManager::instance() + bool success = Context::FileEditManager::instance() .undoAllEditsForRequest(m_currentRequestId, &errorMsg); if (success) { @@ -225,7 +225,7 @@ void FileEditController::updateStats() return; } - auto edits = Context::ChangesManager::instance().getEditsForRequest(m_currentRequestId); + auto edits = Context::FileEditManager::instance().getEditsForRequest(m_currentRequestId); int total = edits.size(); int applied = 0; @@ -234,16 +234,16 @@ void FileEditController::updateStats() for (const auto &edit : edits) { switch (edit.status) { - case Context::ChangesManager::Applied: + case Context::FileEditManager::Applied: applied++; break; - case Context::ChangesManager::Pending: + case Context::FileEditManager::Pending: pending++; break; - case Context::ChangesManager::Rejected: + case Context::FileEditManager::Rejected: rejected++; break; - case Context::ChangesManager::Archived: + case Context::FileEditManager::Archived: total--; break; } diff --git a/sources/ChatView/FileMentionItem.cpp b/sources/ChatView/FileMentionItem.cpp index e608181..bc032c1 100644 --- a/sources/ChatView/FileMentionItem.cpp +++ b/sources/ChatView/FileMentionItem.cpp @@ -4,6 +4,8 @@ #include "FileMentionItem.hpp" +#include "settings/ChatAssistantSettings.hpp" + #include #include #include @@ -11,6 +13,7 @@ #include #include + #include #include @@ -113,8 +116,7 @@ void FileMentionItem::dismiss() emit dismissed(); } -QVariantMap FileMentionItem::applyCurrentSelection( - const QString &text, int cursorPosition, bool useTools) +QVariantMap FileMentionItem::applyCurrentSelection(const QString &text, int cursorPosition) { if (m_currentIndex < 0 || m_currentIndex >= m_searchResults.size()) { dismiss(); @@ -139,8 +141,7 @@ QVariantMap FileMentionItem::applyCurrentSelection( item.value("absolutePath").toString(), item.value("relativePath").toString(), item.value("projectName").toString(), - currentQuery, - useTools); + currentQuery); if (result.value("mode").toString() == "mention") replacement = result.value("mentionText").toString(); @@ -158,8 +159,7 @@ QVariantMap FileMentionItem::handleFileSelection( const QString &absolutePath, const QString &relativePath, const QString &projectName, - const QString ¤tQuery, - bool useTools) + const QString ¤tQuery) { QVariantMap result; const QString fileName = relativePath.section('/', -1); @@ -172,7 +172,7 @@ QVariantMap FileMentionItem::handleFileSelection( mentionKey = projPrefix + ":" + fileName; } - if (useTools) { + if (Settings::chatAssistantSettings().enableChatTools()) { registerMention(mentionKey, absolutePath); result["mode"] = QStringLiteral("mention"); result["mentionText"] = "@" + mentionKey + " "; diff --git a/sources/ChatView/FileMentionItem.hpp b/sources/ChatView/FileMentionItem.hpp index eba52c2..e0f6be0 100644 --- a/sources/ChatView/FileMentionItem.hpp +++ b/sources/ChatView/FileMentionItem.hpp @@ -36,11 +36,9 @@ public: const QString &absolutePath, const QString &relativePath, const QString &projectName, - const QString ¤tQuery, - bool useTools); + const QString ¤tQuery); - Q_INVOKABLE QVariantMap applyCurrentSelection( - const QString &text, int cursorPosition, bool useTools); + Q_INVOKABLE QVariantMap applyCurrentSelection(const QString &text, int cursorPosition); Q_INVOKABLE void registerMention(const QString &mentionKey, const QString &absolutePath); Q_INVOKABLE void clearMentions(); diff --git a/sources/ChatView/InputTokenCounter.cpp b/sources/ChatView/InputTokenCounter.cpp index 0ebd08d..bfbc1ed 100644 --- a/sources/ChatView/InputTokenCounter.cpp +++ b/sources/ChatView/InputTokenCounter.cpp @@ -79,12 +79,6 @@ void InputTokenCounter::setAttachments(const QStringList &attachments) recompute(); } -void InputTokenCounter::setLinkedFiles(const QStringList &linkedFiles) -{ - m_linkedFiles = linkedFiles; - recompute(); -} - void InputTokenCounter::rewireToolsChangedConnection() { if (m_toolsChangedConn) @@ -160,14 +154,11 @@ void InputTokenCounter::recompute() inputTokens += estimateFileTokens(textPaths); } - if (!m_linkedFiles.isEmpty()) { - QStringList textPaths; - inputTokens += splitImageEstimate(m_linkedFiles, textPaths); - inputTokens += estimateFileTokens(textPaths); - } - if (m_session) { for (const Session::MessageRow &row : m_session->rows()) { + if (Session::rowTreatmentFor(Session::RowAudience::TokenCount, row.kind) + == Session::RowTreatment::Omit) + continue; inputTokens += Context::TokenUtils::estimateTokens(row.content); inputTokens += 4; // + role } diff --git a/sources/ChatView/InputTokenCounter.hpp b/sources/ChatView/InputTokenCounter.hpp index c75749b..8dab84b 100644 --- a/sources/ChatView/InputTokenCounter.hpp +++ b/sources/ChatView/InputTokenCounter.hpp @@ -35,7 +35,6 @@ public: void setMessage(const QString &message); void setAttachments(const QStringList &attachments); - void setLinkedFiles(const QStringList &linkedFiles); void recompute(); void recomputeSoon(); @@ -62,7 +61,6 @@ private: QHash m_fileTokens; QStringList m_attachments; - QStringList m_linkedFiles; int m_messageTokens{0}; int m_inputTokens{0}; int m_lastSentEstimate{0}; diff --git a/sources/ChatView/LlmChatBackend.cpp b/sources/ChatView/LlmChatBackend.cpp index fc221b2..1fa905a 100644 --- a/sources/ChatView/LlmChatBackend.cpp +++ b/sources/ChatView/LlmChatBackend.cpp @@ -6,14 +6,18 @@ #include -#include +#include -#include "ChatSerializer.hpp" +#include + +#include "ChatFileStore.hpp" #include "llmcore/ContextData.hpp" #include "logger/Logger.hpp" #include "providers/ProvidersManager.hpp" +#include "session/FencedText.hpp" #include "session/FileEditPayload.hpp" #include "session/HistoryProjection.hpp" +#include "settings/ChatAssistantSettings.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ToolsSettings.hpp" #include "tools/ReadOriginalHistoryTool.hpp" @@ -24,8 +28,16 @@ namespace QodeAssist::Chat { LlmChatBackend::LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent) : Session::ChatBackend(parent) , m_promptProvider(promptProvider) + , m_providerResolver([](const QString &name) { + return Providers::ProvidersManager::instance().getProviderByName(name); + }) {} +void LlmChatBackend::setProviderResolver(ProviderResolver resolver) +{ + m_providerResolver = std::move(resolver); +} + LlmChatBackend::~LlmChatBackend() { cancel(); @@ -42,7 +54,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request) } const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName); + auto *provider = m_providerResolver(providerName); if (!provider) { const QString error = tr("No provider found with name: %1").arg(providerName); @@ -62,7 +74,7 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request) } LLMCore::ContextData context; - if (request.context) + if (request.context && Settings::chatAssistantSettings().useSystemPrompt()) context.systemPrompt = Session::renderSystemPrompt(*request.context); context.history = renderHistory(*request.history, provider, promptTemplate); @@ -73,8 +85,8 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request) promptTemplate, context, LLMCore::RequestType::Chat, - request.options.useTools, - request.options.useThinking); + Settings::chatAssistantSettings().enableChatTools(), + Settings::chatAssistantSettings().enableThinkingMode()); provider->client()->setMaxToolContinuations(Settings::toolsSettings().maxToolContinuations()); provider->client()->setTransferTimeout( @@ -88,10 +100,10 @@ void LlmChatBackend::sendTurn(const Session::TurnRequest &request) m_provider = provider; m_dropPreToolText = !promptTemplate->supportsToolHistory(); - m_requestId - = provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint); + const QString requestId = m_ledger.beginTurn( + provider->sendRequest(QUrl(Settings::generalSettings().caUrl()), payload, endpoint)); - emit sessionEvent(Session::TurnStarted{.turnId = m_requestId}); + emit sessionEvent(Session::TurnStarted{.turnId = requestId}); bindToolSessions(provider); } @@ -102,7 +114,7 @@ void LlmChatBackend::cancel() return; auto *provider = m_provider; - const QString requestId = m_requestId; + const QString requestId = m_ledger.activeTurnId(); releaseRequest(); @@ -114,14 +126,23 @@ void LlmChatBackend::cancel() void LlmChatBackend::releaseRequest() { - if (m_provider) + cancelPendingPermissions(); + + if (m_provider) { disconnect(m_provider->client(), nullptr, this, nullptr); + if (m_provider->toolsManager()) + m_provider->toolsManager()->setExecutionGate({}); + } m_provider = nullptr; - m_requestId.clear(); m_dropPreToolText = false; } +Session::TurnContextNeeds LlmChatBackend::contextNeeds() const +{ + return {Settings::chatAssistantSettings().useSystemPrompt()}; +} + void LlmChatBackend::setChatFilePath(const QString &filePath) { m_chatFilePath = filePath; @@ -133,7 +154,7 @@ void LlmChatBackend::clearToolSession(const QString &filePath) return; const auto providerName = Settings::generalSettings().caProvider(); - auto *provider = Providers::ProvidersManager::instance().getProviderByName(providerName); + auto *provider = m_providerResolver(providerName); if (!provider || !provider->capabilities().testFlag(Providers::ProviderCapability::Tools) || !provider->toolsManager()) { @@ -209,6 +230,106 @@ void LlmChatBackend::bindToolSessions(Providers::Provider *provider) provider->toolsManager()->tool("read_original_history"))) { historyTool->setCurrentSessionId(m_chatFilePath); } + + installExecutionGate(provider); +} + +void LlmChatBackend::installExecutionGate(Providers::Provider *provider) +{ + provider->toolsManager()->setExecutionGate( + [this]( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &input) { + return gateToolExecution(requestId, toolId, toolName, input); + }); +} + +QFuture LlmChatBackend::gateToolExecution( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &input) +{ + const auto allow = [] { + QPromise promise; + promise.start(); + promise.addResult(true); + promise.finish(); + return promise.future(); + }; + + if (!m_ledger.isActiveTurn(requestId)) + return allow(); + + if (!m_provider || !m_provider->toolsManager()) + return allow(); + + auto *tool = m_provider->toolsManager()->tool(toolName); + if (tool && tool->safety() == ::LLMQore::ToolSafety::ReadOnly) + return allow(); + + auto promise = std::make_shared>(); + promise->start(); + + const QString permissionId = m_ledger.registerPermission( + [promise](const QString &optionId) { + const bool allowed = optionId == Session::PermissionOptionKind::AllowOnce + || optionId == Session::PermissionOptionKind::AllowAlways; + promise->addResult(allowed); + promise->finish(); + }, + [promise] { + promise->addResult(false); + promise->finish(); + }); + + QFuture decision = promise->future(); + + const QString title = tool && !tool->displayName().isEmpty() + ? tr("Run %1").arg(tool->displayName()) + : tr("Run %1").arg(toolName); + + emit sessionEvent( + Session::PermissionRequested{ + .turnId = requestId, + .requestId = permissionId, + .toolCallId = toolId, + .title = title, + .toolKind = toolName, + .options + = {Session::PermissionOption{"allow_once", tr("Allow"), "allow_once"}, + Session::PermissionOption{ + "allow_always", tr("Allow for this conversation"), "allow_always"}, + Session::PermissionOption{"reject_once", tr("Don't run it"), "reject_once"}}}); + + Q_UNUSED(input) + return decision; +} + +bool LlmChatBackend::respondPermission(const QString &requestId, const QString &optionId) +{ + if (!m_ledger.resolvePermission(requestId, optionId)) + return false; + + emit sessionEvent( + Session::PermissionResolved{ + .turnId = m_ledger.activeTurnId(), .requestId = requestId, .optionId = optionId}); + + return true; +} + +void LlmChatBackend::cancelPendingPermissions() +{ + const QString turnId = m_ledger.activeTurnId(); + const QStringList cancelled = m_ledger.endTurn(); + + for (const QString &requestId : cancelled) { + emit sessionEvent( + Session::PermissionResolved{ + .turnId = turnId, .requestId = requestId, .cancelled = true}); + } } QVector LlmChatBackend::renderHistory( @@ -222,7 +343,10 @@ QVector LlmChatBackend::renderHistory( int toolCallMsgIdx = -1; for (const Session::MessageRow &row : Session::projectToRows(history)) { - if (row.kind == Session::RowKind::Tool) { + const Session::RowTreatment treatment + = Session::rowTreatmentFor(Session::RowAudience::Prompt, row.kind); + + if (treatment == Session::RowTreatment::ToolExchange) { if (!toolHistory || row.toolName.isEmpty()) continue; @@ -250,29 +374,29 @@ QVector LlmChatBackend::renderHistory( toolCallMsgIdx = -1; - if (row.kind == Session::RowKind::FileEdit) + if (treatment == Session::RowTreatment::Omit) continue; LLMCore::Message apiMessage; - apiMessage.role = row.kind == Session::RowKind::User ? "user" : "assistant"; + apiMessage.role = treatment == Session::RowTreatment::UserText ? "user" : "assistant"; apiMessage.content = row.content; if (!row.attachments.isEmpty() && !m_chatFilePath.isEmpty()) { apiMessage.content += "\n\nAttached files:"; for (const Session::AttachmentBlock &attachment : row.attachments) { - const QString fileContent - = ChatSerializer::loadContentFromStorage(m_chatFilePath, attachment.storedPath); + const QByteArray fileContent = ChatFileStore::loadRawContentFromStorage( + m_chatFilePath, attachment.storedPath); if (fileContent.isEmpty()) continue; - const QString decodedContent = QString::fromUtf8( - QByteArray::fromBase64(fileContent.toUtf8())); - apiMessage.content += QString("\n\nFile: %1\n```\n%2\n```") - .arg(attachment.fileName, decodedContent); + apiMessage.content + += "\n\n" + + Session::fencedFileBlock( + attachment.fileName, QString::fromUtf8(fileContent)); } } - apiMessage.isThinking = row.kind == Session::RowKind::Thinking; + apiMessage.isThinking = treatment == Session::RowTreatment::AssistantThinking; apiMessage.isRedacted = row.redacted; apiMessage.signature = row.signature; @@ -296,7 +420,7 @@ QVector LlmChatBackend::loadImagesFromStorage( for (const Session::ImageBlock &storedImage : storedImages) { const QString base64Data - = ChatSerializer::loadContentFromStorage(m_chatFilePath, storedImage.storedPath); + = ChatFileStore::loadContentFromStorage(m_chatFilePath, storedImage.storedPath); if (base64Data.isEmpty()) { LOG_MESSAGE(QString("Warning: Failed to load image: %1").arg(storedImage.storedPath)); continue; @@ -315,7 +439,7 @@ QVector LlmChatBackend::loadImagesFromStorage( void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk) { - if (requestId != m_requestId) + if (!m_ledger.isActiveTurn(requestId)) return; emit sessionEvent(Session::TextDelta{.turnId = requestId, .text = chunk}); @@ -323,7 +447,7 @@ void LlmChatBackend::handleChunk(const QString &requestId, const QString &chunk) void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fullText) { - if (requestId != m_requestId) + if (!m_ledger.isActiveTurn(requestId)) return; LOG_MESSAGE( @@ -337,7 +461,7 @@ void LlmChatBackend::handleCompleted(const QString &requestId, const QString &fu void LlmChatBackend::handleFinalized( const ::LLMQore::RequestID &requestId, const ::LLMQore::CompletionInfo &info) { - if (requestId != m_requestId || !info.usage) + if (!m_ledger.isActiveTurn(requestId) || !info.usage) return; const auto &usage = *info.usage; @@ -361,7 +485,7 @@ void LlmChatBackend::handleFinalized( void LlmChatBackend::handleFailed(const QString &requestId, const QString &error) { - if (requestId != m_requestId) + if (!m_ledger.isActiveTurn(requestId)) return; LOG_MESSAGE(QString("Chat request %1 failed: %2").arg(requestId, error)); @@ -374,7 +498,7 @@ void LlmChatBackend::handleFailed(const QString &requestId, const QString &error void LlmChatBackend::handleThinkingBlock( const QString &requestId, const QString &thinking, const QString &signature) { - if (requestId != m_requestId) + if (!m_ledger.isActiveTurn(requestId)) return; emit sessionEvent( @@ -391,14 +515,15 @@ void LlmChatBackend::handleToolStarted( const QString &toolName, const QJsonObject &arguments) { - if (requestId != m_requestId) + if (!m_ledger.isActiveTurn(requestId)) return; emit sessionEvent( - Session::ToolCallStarted{ + Session::ToolCallUpdated{ .turnId = requestId, .toolId = toolId, .name = toolName, + .status = QStringLiteral("in_progress"), .arguments = arguments, .dropPrecedingText = m_dropPreToolText}); } @@ -409,12 +534,17 @@ void LlmChatBackend::handleToolResult( const QString &toolName, const QString &toolOutput) { - if (requestId != m_requestId) + if (!m_ledger.isActiveTurn(requestId)) return; + const bool failed = toolOutput.startsWith(QLatin1String("Error: ")); emit sessionEvent( - Session::ToolCallCompleted{ - .turnId = requestId, .toolId = toolId, .name = toolName, .result = toolOutput}); + Session::ToolCallUpdated{ + .turnId = requestId, + .toolId = toolId, + .name = toolName, + .status = failed ? QStringLiteral("failed") : QStringLiteral("completed"), + .result = toolOutput}); } } // namespace QodeAssist::Chat diff --git a/sources/ChatView/LlmChatBackend.hpp b/sources/ChatView/LlmChatBackend.hpp index 104e3c2..1356970 100644 --- a/sources/ChatView/LlmChatBackend.hpp +++ b/sources/ChatView/LlmChatBackend.hpp @@ -4,13 +4,16 @@ #pragma once -#include +#include + +#include #include #include #include "providers/Provider.hpp" #include "session/ChatBackend.hpp" +#include "session/TurnLedger.hpp" #include "templates/IPromptProvider.hpp" namespace QodeAssist::Chat { @@ -20,19 +23,32 @@ class LlmChatBackend : public Session::ChatBackend Q_OBJECT public: + using ProviderResolver = std::function; + explicit LlmChatBackend(Templates::IPromptProvider *promptProvider, QObject *parent = nullptr); ~LlmChatBackend() override; + void setProviderResolver(ProviderResolver resolver); + void sendTurn(const Session::TurnRequest &request) override; void cancel() override; + bool respondPermission(const QString &requestId, const QString &optionId) override; + Session::TurnContextNeeds contextNeeds() const override; - void setChatFilePath(const QString &filePath); - void clearToolSession(const QString &filePath); + void setChatFilePath(const QString &filePath) override; + void clearToolSession(const QString &filePath) override; private: void connectClient(Providers::Provider *provider); void releaseRequest(); void bindToolSessions(Providers::Provider *provider); + void installExecutionGate(Providers::Provider *provider); + QFuture gateToolExecution( + const QString &requestId, + const QString &toolId, + const QString &toolName, + const QJsonObject &input); + void cancelPendingPermissions(); QVector renderHistory( const Session::ConversationHistory &history, Providers::Provider *provider, @@ -59,10 +75,11 @@ private: const QString &toolOutput); Templates::IPromptProvider *m_promptProvider = nullptr; + ProviderResolver m_providerResolver; QString m_chatFilePath; Providers::Provider *m_provider = nullptr; - QString m_requestId; + Session::TurnLedger m_ledger; bool m_dropPreToolText = false; }; diff --git a/sources/ChatView/TurnContextAdapters.cpp b/sources/ChatView/TurnContextAdapters.cpp index a60ad01..df4bbd9 100644 --- a/sources/ChatView/TurnContextAdapters.cpp +++ b/sources/ChatView/TurnContextAdapters.cpp @@ -4,18 +4,31 @@ #include "TurnContextAdapters.hpp" +#include #include #include +#include #include #include "ProjectSettings.hpp" #include "SkillsSettings.hpp" -#include "context/ContextManager.hpp" -#include "context/RulesLoader.hpp" #include "skills/SkillsManager.hpp" namespace QodeAssist::Chat { +ProjectExplorer::Project *activeProject() +{ + auto currentEditor = Core::EditorManager::currentEditor(); + if (currentEditor && currentEditor->document()) { + auto project = ProjectExplorer::ProjectManager::projectForFile( + currentEditor->document()->filePath()); + if (project) + return project; + } + + return ProjectExplorer::ProjectManager::startupProject(); +} + ProjectContextQtCreator::ProjectContextQtCreator(ProjectExplorer::Project *project) : m_project(project) {} @@ -38,14 +51,6 @@ Session::ProjectInfo ProjectContextQtCreator::projectInfo() const return info; } -QString ProjectContextQtCreator::projectRules() const -{ - if (!m_project) - return {}; - - return Context::RulesLoader::loadRulesForProject(m_project, Context::RulesContext::Chat); -} - SkillsContextQtCreator::SkillsContextQtCreator( Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project) : m_skillsManager(skillsManager) @@ -81,19 +86,6 @@ std::optional SkillsContextQtCreator::findSkill(const QSt return Session::InvokedSkill{skill->name, skill->body}; } -LinkedFilesQtCreator::LinkedFilesQtCreator(Context::ContextManager *contextManager) - : m_contextManager(contextManager) -{} - -QList LinkedFilesQtCreator::readFiles(const QList &paths) const -{ - QList files; - for (const auto &file : m_contextManager->getContentFiles(paths)) - files.append(Session::LinkedFile{file.filename, file.content}); - - return files; -} - std::unique_ptr makeSkillsContext( Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project) { diff --git a/sources/ChatView/TurnContextAdapters.hpp b/sources/ChatView/TurnContextAdapters.hpp index fdc96b3..cc5df67 100644 --- a/sources/ChatView/TurnContextAdapters.hpp +++ b/sources/ChatView/TurnContextAdapters.hpp @@ -12,23 +12,20 @@ namespace ProjectExplorer { class Project; } -namespace QodeAssist::Context { -class ContextManager; -} - namespace QodeAssist::Skills { class SkillsManager; } namespace QodeAssist::Chat { +ProjectExplorer::Project *activeProject(); + class ProjectContextQtCreator : public Session::IProjectContextPort { public: explicit ProjectContextQtCreator(ProjectExplorer::Project *project); Session::ProjectInfo projectInfo() const override; - QString projectRules() const override; private: ProjectExplorer::Project *m_project = nullptr; @@ -47,17 +44,6 @@ private: Skills::SkillsManager *m_skillsManager = nullptr; }; -class LinkedFilesQtCreator : public Session::ILinkedFilesPort -{ -public: - explicit LinkedFilesQtCreator(Context::ContextManager *contextManager); - - QList readFiles(const QList &paths) const override; - -private: - Context::ContextManager *m_contextManager = nullptr; -}; - std::unique_ptr makeSkillsContext( Skills::SkillsManager *skillsManager, ProjectExplorer::Project *project); diff --git a/sources/ChatView/icons/link-file-dark.svg b/sources/ChatView/icons/link-file-dark.svg deleted file mode 100644 index 5a1acda..0000000 --- a/sources/ChatView/icons/link-file-dark.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/sources/ChatView/icons/link-file-light.svg b/sources/ChatView/icons/link-file-light.svg deleted file mode 100644 index 6c37bc0..0000000 --- a/sources/ChatView/icons/link-file-light.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/sources/ChatView/qml/RootItem.qml b/sources/ChatView/qml/RootItem.qml index 58f615a..e920ca5 100644 --- a/sources/ChatView/qml/RootItem.qml +++ b/sources/ChatView/qml/RootItem.qml @@ -47,23 +47,16 @@ ChatRootView { color: palette.window } - SplitDropZone { + DropZone { anchors.fill: parent z: 99 - onFilesDroppedToAttach: (urlStrings) => { + onFilesDropped: (urlStrings) => { var localPaths = root.convertUrlsToLocalPaths(urlStrings) if (localPaths.length > 0) { root.addFilesToAttachList(localPaths) } } - - onFilesDroppedToLink: (urlStrings) => { - var localPaths = root.convertUrlsToLocalPaths(urlStrings) - if (localPaths.length > 0) { - root.addFilesToLinkList(localPaths) - } - } } QoABusyOverlay { @@ -76,7 +69,7 @@ ChatRootView { anchors.bottomMargin: bottomBar.height active: root.isCompressing - text: qsTr("Compressing chat…") + text: root.isAgentBound ? qsTr("Preparing the handover summary…") : qsTr("Compressing chat…") } ColumnLayout { @@ -138,19 +131,6 @@ ChatRootView { relocateTooltip.text: (typeof _chatview !== 'undefined') ? qsTr("Move this chat to an editor tab") : qsTr("Move this chat to a separate window") - toolsButton { - checked: root.useTools - onCheckedChanged: { - root.useTools = toolsButton.checked - } - } - thinkingMode { - checked: root.useThinking - enabled: root.isThinkingSupport - onCheckedChanged: { - root.useThinking = thinkingMode.checked - } - } settingsButton.onClicked: root.openSettings() configSelector { model: root.availableConfigurations @@ -165,18 +145,6 @@ ChatRootView { root.loadAvailableConfigurations() } } - - roleSelector { - model: root.availableAgentRoles - displayText: root.currentAgentRole - onActivated: function(index) { - root.applyAgentRole(root.availableAgentRoles[index]) - } - - popup.onAboutToShow: { - root.loadAvailableAgentRoles() - } - } } RowLayout { @@ -248,6 +216,10 @@ ChatRootView { return fileEditMessageComponent } else if (model.roleType === ChatModel.Thinking) { return thinkingMessageComponent + } else if (model.roleType === ChatModel.Permission) { + return permissionMessageComponent + } else if (model.roleType === ChatModel.Plan) { + return planMessageComponent } else { return chatItemComponent } @@ -357,7 +329,20 @@ ChatRootView { ToolBlock { width: parent.width - toolContent: model.content + toolName: model.toolName || "" + toolResult: model.toolResult || "" + toolKind: model.toolKind || "" + toolStatus: model.toolStatus || "" + toolDetails: model.toolDetails || ({}) + } + } + + Component { + id: planMessageComponent + + PlanBlock { + width: parent.width + planContent: model.content } } @@ -386,6 +371,19 @@ ChatRootView { } } + Component { + id: permissionMessageComponent + + PermissionBlock { + width: parent.width + permissionContent: model.content + + onRespond: function(requestId, optionId) { + root.respondToPermission(requestId, optionId) + } + } + } + Component { id: thinkingMessageComponent @@ -405,12 +403,76 @@ ChatRootView { } } + Rectangle { + id: agentSessionBanner + + Layout.fillWidth: true + Layout.margins: 5 + visible: root.agentSessionIssue.length > 0 + implicitHeight: bannerLayout.implicitHeight + 16 + radius: 4 + color: palette.base + border.width: 1 + border.color: palette.mid + + ColumnLayout { + id: bannerLayout + + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 8 + } + spacing: 6 + + Text { + Layout.fillWidth: true + text: root.agentSessionIssue + textFormat: Text.PlainText + color: palette.text + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + visible: root.canStartNewAgentSession + spacing: 8 + + QoAButton { + text: qsTr("Continue with a new session") + onClicked: root.startNewAgentSession() + } + + QoAButton { + id: handoverButton + + text: qsTr("Continue with a summary") + enabled: root.canHandOverSummary + + onClicked: root.startNewAgentSessionWithSummary() + + QoAToolTip { + visible: handoverButton.hovered + && root.summaryHandoverTooltip.length > 0 + text: root.summaryHandoverTooltip + delay: 300 + } + } + + Item { Layout.fillWidth: true } + } + } + } + ScrollView { id: view Layout.fillWidth: true Layout.minimumHeight: 30 Layout.maximumHeight: root.height / 2 + enabled: root.agentSessionIssue.length === 0 QQC.TextArea { id: messageInput @@ -452,20 +514,7 @@ ChatRootView { } } fileMentionPopup.dismiss() - - const slashIndex = textBefore.lastIndexOf('/') - if (slashIndex >= 0) { - const beforeSlash = slashIndex === 0 - ? ' ' - : textBefore.charAt(slashIndex - 1) - const skillQuery = textBefore.substring(slashIndex + 1) - if ((beforeSlash === ' ' || beforeSlash === '\n') - && /^[a-z0-9-]*$/.test(skillQuery)) { - skillCommandPopup.updateSearch(skillQuery) - return - } - } - skillCommandPopup.dismiss() + root.refreshSlashPopup() } Keys.onPressed: function(event) { @@ -561,17 +610,6 @@ ChatRootView { onRemoveFileFromListByIndex: (index) => root.removeFileFromAttachList(index) } - AttachedFilesPlace { - id: linkedFilesPlace - - Layout.fillWidth: true - attachedFilesModel: root.linkedFiles - iconPath: palette.window.hslLightness > 0.5 ? "qrc:/qt/qml/ChatView/icons/link-file-dark.svg" - : "qrc:/qt/qml/ChatView/icons/link-file-light.svg" - accentColor: Qt.tint(palette.mid, Qt.rgba(0, 0.3, 0.8, 0.4)) - onRemoveFileFromListByIndex: (index) => root.removeFileFromLinkList(index) - } - FileEditsActionBar { id: fileEditsActionBar @@ -593,6 +631,7 @@ ChatRootView { isCompressing: root.isCompressing isProcessing: root.isRequestInProgress + agentMode: root.isAgentBound sendButton.onClicked: !root.isRequestInProgress ? root.sendChatMessage() : root.cancelRequest() sendButton.icon.source: root.isRequestInProgress @@ -608,14 +647,12 @@ ChatRootView { ? root.lastErrorMessage : qsTr("Send message to LLM %1").arg(root.sendShortcutText)) compressButton.onClicked: compressConfirmDialog.open() + compressButton.enabled: root.canShrinkContext + compressButton.text: root.isAgentBound ? qsTr("Hand over") : qsTr("Compress") + compressTooltip.text: root.shrinkContextTooltip cancelCompressButton.onClicked: root.cancelCompression() - syncOpenFiles { - checked: root.isSyncOpenFiles - onCheckedChanged: root.setIsSyncOpenFiles(bottomBar.syncOpenFiles.checked) - } attachFiles.onClicked: root.showAttachFilesDialog() attachImages.onClicked: root.showAddImageDialog() - linkFiles.onClicked: root.showLinkFilesDialog() } } @@ -656,13 +693,36 @@ ChatRootView { function applyMentionSelection() { var result = fileMentionPopup.applyCurrentSelection( - messageInput.text, messageInput.cursorPosition, root.useTools) + messageInput.text, messageInput.cursorPosition) if (result.text !== undefined) { messageInput.text = result.text messageInput.cursorPosition = result.cursorPosition } } + onSlashCommandsChanged: { + if (skillCommandPopup.visible) + refreshSlashPopup() + } + + function refreshSlashPopup() { + const cursorPos = messageInput.cursorPosition + const textBefore = messageInput.text.substring(0, cursorPos) + const slashIndex = textBefore.lastIndexOf('/') + if (slashIndex >= 0) { + const beforeSlash = slashIndex === 0 + ? ' ' + : textBefore.charAt(slashIndex - 1) + const skillQuery = textBefore.substring(slashIndex + 1) + if ((beforeSlash === ' ' || beforeSlash === '\n') + && /^\S*$/.test(skillQuery)) { + skillCommandPopup.updateSearch(skillQuery) + return + } + } + skillCommandPopup.dismiss() + } + function applySkillSelection() { const name = skillCommandPopup.currentName() if (name === "") @@ -688,16 +748,42 @@ ChatRootView { scrollToBottom() } + onChatTargetSwitchNeedsNewChat: function(targetName) { + chatTargetSwitchDialog.targetName = targetName + chatTargetSwitchDialog.open() + } + Dialog { - id: compressConfirmDialog + id: chatTargetSwitchDialog + + property string targetName: "" anchors.centerIn: parent - title: qsTr("Compress Chat") + title: qsTr("Start a New Conversation") modal: true standardButtons: Dialog.Yes | Dialog.No Label { - text: qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.") + text: qsTr("A conversation stays with the kind it started with, so switching to %1 needs a new one.\n\nClear this chat and switch?").arg(chatTargetSwitchDialog.targetName) + wrapMode: Text.WordWrap + } + + onAccepted: root.confirmChatTargetSwitch() + onRejected: root.cancelChatTargetSwitch() + } + + Dialog { + id: compressConfirmDialog + + anchors.centerIn: parent + title: root.isAgentBound ? qsTr("Hand Over Session") : qsTr("Compress Chat") + modal: true + standardButtons: Dialog.Yes | Dialog.No + + Label { + text: root.isAgentBound + ? qsTr("Summarise this conversation and continue in a fresh agent session?\n\nThe summary will be generated by the LLM chat configuration and given to the new session as context.") + : qsTr("Create a summarized copy of this chat?\n\nThe summary will be generated by LLM and saved as a new chat file.") wrapMode: Text.WordWrap } @@ -840,19 +926,8 @@ ChatRootView { y: (parent.height - height) / 2 baseSystemPrompt: root.baseSystemPrompt - currentAgentRole: root.currentAgentRole - currentAgentRoleDescription: root.currentAgentRoleDescription - currentAgentRoleSystemPrompt: root.currentAgentRoleSystemPrompt - activeRules: root.activeRules - activeRulesCount: root.activeRulesCount onOpenSettings: root.openSettings() - onOpenAgentRolesSettings: root.openAgentRolesSettings() - onOpenRulesFolder: root.openRulesFolder() - onRefreshRules: root.refreshRules() - onRuleSelected: function(index) { - contextViewer.selectedRuleContent = root.getRuleContent(index) - } } Connections { diff --git a/sources/ChatView/qml/chatparts/BlockPayload.js b/sources/ChatView/qml/chatparts/BlockPayload.js new file mode 100644 index 0000000..09f6792 --- /dev/null +++ b/sources/ChatView/qml/chatparts/BlockPayload.js @@ -0,0 +1,29 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +.pragma library + +function parseMarkerPayload(marker, content) { + if (!content || !content.startsWith(marker)) + return null; + + try { + const parsed = JSON.parse(content.substring(marker.length)); + return (parsed && typeof parsed === "object") ? parsed : null; + } catch (e) { + return null; + } +} + +function parseFileEdit(content) { + return parseMarkerPayload("QODEASSIST_FILE_EDIT:", content); +} + +function parsePermission(content) { + return parseMarkerPayload("QODEASSIST_PERMISSION:", content); +} + +function parsePlan(content) { + return parseMarkerPayload("QODEASSIST_PLAN:", content); +} diff --git a/sources/ChatView/qml/chatparts/FileEditBlock.qml b/sources/ChatView/qml/chatparts/FileEditBlock.qml index c913b64..de7edb4 100644 --- a/sources/ChatView/qml/chatparts/FileEditBlock.qml +++ b/sources/ChatView/qml/chatparts/FileEditBlock.qml @@ -8,6 +8,7 @@ import QtQuick.Layouts import UIControls import ChatView import Qt.labs.platform as Platform +import "BlockPayload.js" as BlockPayload Rectangle { id: root @@ -39,13 +40,11 @@ Rectangle { readonly property bool isArchived: editStatus === "archived" readonly property color appliedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8) - readonly property color revertedColor: Qt.rgba(0.8, 0.6, 0.2, 0.8) readonly property color rejectedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8) readonly property color archivedColor: Qt.rgba(0.5, 0.5, 0.5, 0.8) readonly property color pendingColor: palette.highlight readonly property color appliedBgColor: Qt.rgba(0.2, 0.8, 0.2, 0.3) - readonly property color revertedBgColor: Qt.rgba(0.8, 0.6, 0.2, 0.3) readonly property color rejectedBgColor: Qt.rgba(0.8, 0.2, 0.2, 0.3) readonly property color archivedBgColor: Qt.rgba(0.5, 0.5, 0.5, 0.3) @@ -84,23 +83,15 @@ Rectangle { readonly property int removedLines: countLines(oldContent) function parseEditData(content) { - try { - const marker = "QODEASSIST_FILE_EDIT:"; - let jsonStr = content; - if (content.indexOf(marker) >= 0) { - jsonStr = content.substring(content.indexOf(marker) + marker.length); - } - return JSON.parse(jsonStr); - } catch (e) { - return { - edit_id: "", - file: "", - old_content: "", - new_content: "", - status: "error", - status_message: "" - }; - } + const parsed = BlockPayload.parseFileEdit(content); + return parsed !== null ? parsed : { + edit_id: "", + file: "", + old_content: "", + new_content: "", + status: "error", + status_message: "" + }; } function getFileName(path) { diff --git a/sources/ChatView/qml/chatparts/PermissionBlock.qml b/sources/ChatView/qml/chatparts/PermissionBlock.qml new file mode 100644 index 0000000..d15113e --- /dev/null +++ b/sources/ChatView/qml/chatparts/PermissionBlock.qml @@ -0,0 +1,208 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +import QtQuick +import QtQuick.Layouts +import UIControls +import "BlockPayload.js" as BlockPayload + +Rectangle { + id: root + + property string permissionContent: "" + + readonly property var permissionData: parsePermissionData(permissionContent) + readonly property bool isMalformed: permissionData === null + readonly property string requestId: isMalformed ? "" : (permissionData.requestId || "") + readonly property string title: isMalformed + ? qsTr("Unreadable permission request") + : (permissionData.title || qsTr("The agent asks for permission")) + readonly property string toolKind: isMalformed ? "" : (permissionData.toolKind || "") + readonly property var options: isMalformed ? [] : (permissionData.options || []) + readonly property string status: isMalformed ? "error" : (permissionData.status || "pending") + readonly property string selectedOptionId: isMalformed ? "" : (permissionData.selectedOptionId || "") + readonly property bool automatic: !isMalformed && permissionData.automatic === true + + readonly property bool isPending: status === "pending" && options.length > 0 + readonly property bool isCancelled: status === "cancelled" + readonly property bool wasDeclinedUnpresentable: isCancelled && options.length === 0 + + readonly property var selectedOption: findOption(selectedOptionId) + readonly property bool wasAllowed: selectedOption !== null + && selectedOption.allows === true + + readonly property int borderRadius: 6 + readonly property int contentMargin: 10 + readonly property int badgeRadius: 3 + readonly property int badgePaddingH: 12 + readonly property int badgePaddingV: 6 + readonly property int titleMaxLines: 4 + readonly property int rowSpacing: 8 + + readonly property color allowedColor: Qt.rgba(0.2, 0.8, 0.2, 0.8) + readonly property color deniedColor: Qt.rgba(0.8, 0.2, 0.2, 0.8) + readonly property color cancelledColor: Qt.rgba(0.5, 0.5, 0.5, 0.8) + + readonly property color statusColor: { + if (isMalformed) + return deniedColor; + if (isPending) + return palette.highlight; + if (isCancelled) + return cancelledColor; + return wasAllowed ? allowedColor : deniedColor; + } + + readonly property string statusText: { + if (isMalformed) + return qsTr("UNREADABLE"); + if (isPending) + return qsTr("WAITING FOR YOU"); + if (wasDeclinedUnpresentable) + return qsTr("DECLINED"); + if (isCancelled) + return qsTr("NO LONGER AVAILABLE"); + if (automatic) + return wasAllowed ? qsTr("ALLOWED AUTOMATICALLY") : qsTr("DENIED AUTOMATICALLY"); + return wasAllowed ? qsTr("ALLOWED") : qsTr("DENIED"); + } + + readonly property string explanation: { + if (isMalformed) + return qsTr("This permission record could not be read, so it cannot be answered."); + if (wasDeclinedUnpresentable) + return qsTr("The agent did not offer a set of options this chat could present safely, so the request was declined."); + if (isCancelled) + return qsTr("This request ended before it was answered."); + if (isPending) + return ""; + if (selectedOption === null) + return ""; + return automatic + ? qsTr("Answered automatically with \"%1\", because you chose that for this action type for the rest of the conversation.").arg(selectedOption.name) + : qsTr("You answered \"%1\".").arg(selectedOption.name); + } + + signal respond(string requestId, string optionId) + + implicitHeight: layout.implicitHeight + 2 * contentMargin + radius: borderRadius + color: palette.base + border.width: 1 + border.color: statusColor + + function parsePermissionData(content) { + return BlockPayload.parsePermission(content); + } + + function findOption(optionId) { + if (!optionId) + return null; + for (let i = 0; i < root.options.length; ++i) { + if (root.options[i].id === optionId) + return root.options[i]; + } + return null; + } + + ColumnLayout { + id: layout + + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: root.contentMargin + } + spacing: root.rowSpacing + + RowLayout { + Layout.fillWidth: true + spacing: root.rowSpacing + + Rectangle { + Layout.preferredWidth: statusLabel.implicitWidth + root.badgePaddingH + Layout.preferredHeight: statusLabel.implicitHeight + root.badgePaddingV + Layout.alignment: Qt.AlignTop + radius: root.badgeRadius + color: root.statusColor + + Text { + id: statusLabel + + anchors.centerIn: parent + text: root.statusText + textFormat: Text.PlainText + font.pixelSize: 10 + font.bold: true + color: palette.base + } + } + + Text { + Layout.fillWidth: true + text: root.title + textFormat: Text.PlainText + font.pixelSize: 13 + font.bold: true + color: palette.text + wrapMode: Text.WordWrap + maximumLineCount: root.titleMaxLines + elide: Text.ElideRight + } + } + + Text { + Layout.fillWidth: true + visible: root.toolKind.length > 0 + text: qsTr("Action type: %1").arg(root.toolKind) + textFormat: Text.PlainText + font.pixelSize: 11 + color: palette.placeholderText + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + visible: text.length > 0 + text: root.explanation + textFormat: Text.PlainText + font.pixelSize: 11 + color: palette.placeholderText + wrapMode: Text.WordWrap + } + + Flow { + Layout.fillWidth: true + visible: root.isPending + spacing: root.rowSpacing + + Repeater { + model: root.options + + delegate: QoAButton { + id: optionButton + + required property var modelData + + text: optionButton.modelData.name || optionButton.modelData.id + accentColor: optionButton.modelData.allows === true + ? root.allowedColor + : root.deniedColor + + contentItem: Text { + text: optionButton.text + textFormat: Text.PlainText + color: palette.buttonText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + onClicked: root.respond(root.requestId, optionButton.modelData.id) + } + } + } + } +} diff --git a/sources/ChatView/qml/chatparts/PlanBlock.qml b/sources/ChatView/qml/chatparts/PlanBlock.qml new file mode 100644 index 0000000..2c80ca1 --- /dev/null +++ b/sources/ChatView/qml/chatparts/PlanBlock.qml @@ -0,0 +1,151 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +import QtQuick +import QtQuick.Layouts +import "BlockPayload.js" as BlockPayload + +Rectangle { + id: root + + property string planContent: "" + property bool expanded: true + + readonly property var planData: parsePlanData(planContent) + readonly property var entries: planData === null ? [] : (planData.entries || []) + readonly property int completedCount: countCompleted() + readonly property bool isComplete: entries.length > 0 && completedCount === entries.length + + readonly property int borderRadius: 6 + readonly property int contentMargin: 10 + readonly property int rowSpacing: 6 + + readonly property color completedColor: Qt.rgba(0.2, 0.8, 0.2, 0.9) + readonly property color activeColor: palette.highlight + readonly property color pendingColor: palette.placeholderText + + implicitHeight: layout.implicitHeight + 2 * contentMargin + radius: borderRadius + color: palette.base + border.width: 1 + border.color: isComplete ? completedColor : palette.mid + + function parsePlanData(content) { + return BlockPayload.parsePlan(content); + } + + function countCompleted() { + let done = 0; + for (let i = 0; i < root.entries.length; ++i) { + if (root.entries[i].status === "completed") + ++done; + } + return done; + } + + function entryColor(status) { + if (status === "completed") + return root.completedColor; + if (status === "in_progress") + return root.activeColor; + return root.pendingColor; + } + + function entryMarker(status) { + if (status === "completed") + return "✓"; + if (status === "in_progress") + return "▶"; + return "○"; + } + + ColumnLayout { + id: layout + + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: root.contentMargin + } + spacing: root.rowSpacing + + MouseArea { + Layout.fillWidth: true + Layout.preferredHeight: header.implicitHeight + cursorShape: Qt.PointingHandCursor + onClicked: root.expanded = !root.expanded + + RowLayout { + id: header + + anchors.fill: parent + spacing: root.rowSpacing + + Text { + text: qsTr("Plan") + textFormat: Text.PlainText + font.pixelSize: 13 + font.bold: true + color: palette.text + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 of %2 done").arg(root.completedCount).arg(root.entries.length) + textFormat: Text.PlainText + font.pixelSize: 11 + color: palette.placeholderText + } + + Text { + text: root.expanded ? "▼" : "▶" + font.pixelSize: 10 + color: palette.mid + } + } + } + + Repeater { + model: root.expanded ? root.entries : [] + + delegate: RowLayout { + id: entryRow + + required property var modelData + + Layout.fillWidth: true + spacing: root.rowSpacing + + Text { + Layout.alignment: Qt.AlignTop + text: root.entryMarker(entryRow.modelData.status) + textFormat: Text.PlainText + font.pixelSize: 12 + color: root.entryColor(entryRow.modelData.status) + } + + Text { + Layout.fillWidth: true + text: entryRow.modelData.content || "" + textFormat: Text.PlainText + font.pixelSize: 12 + font.strikeout: entryRow.modelData.status === "completed" + color: entryRow.modelData.status === "completed" ? palette.placeholderText + : palette.text + wrapMode: Text.WordWrap + } + + Text { + Layout.alignment: Qt.AlignTop + visible: entryRow.modelData.priority === "high" + text: qsTr("high") + textFormat: Text.PlainText + font.pixelSize: 10 + color: palette.highlight + } + } + } + } +} diff --git a/sources/ChatView/qml/chatparts/ToolBlock.qml b/sources/ChatView/qml/chatparts/ToolBlock.qml index a4df730..e975302 100644 --- a/sources/ChatView/qml/chatparts/ToolBlock.qml +++ b/sources/ChatView/qml/chatparts/ToolBlock.qml @@ -8,14 +8,52 @@ import Qt.labs.platform as Platform Rectangle { id: root - property string toolContent: "" + property string toolName: "" + property string toolResult: "" + property string toolKind: "" + property string toolStatus: "" + property var toolDetails: ({}) property bool expanded: false property alias headerOpacity: headerRow.opacity - readonly property int firstNewline: toolContent.indexOf('\n') - readonly property string toolName: firstNewline > 0 ? toolContent.substring(0, firstNewline) : toolContent - readonly property string toolResult: firstNewline > 0 ? toolContent.substring(firstNewline + 1) : "" + readonly property int legacyNewline: toolName === "" ? toolResult.indexOf('\n') : -1 + readonly property string headerName: { + if (toolName !== "") + return toolName; + return legacyNewline > 0 ? toolResult.substring(0, legacyNewline) : toolResult; + } + readonly property string resultText: { + if (toolName !== "") + return toolResult; + return legacyNewline > 0 ? toolResult.substring(legacyNewline + 1) : ""; + } + + readonly property var locations: (toolDetails && toolDetails.locations) ? toolDetails.locations : [] + readonly property var diffs: (toolDetails && toolDetails.diffs) ? toolDetails.diffs : [] + + readonly property bool isRunning: toolStatus === "pending" || toolStatus === "in_progress" + readonly property bool hasFailed: toolStatus === "failed" + + readonly property color statusColor: { + if (hasFailed) + return Qt.rgba(0.8, 0.2, 0.2, 0.9); + if (isRunning) + return palette.highlight; + if (toolStatus === "completed") + return Qt.rgba(0.2, 0.8, 0.2, 0.9); + return palette.mid; + } + + readonly property string statusMarker: { + if (hasFailed) + return "✗"; + if (toolStatus === "completed") + return "✓"; + if (isRunning) + return "…"; + return ""; + } radius: 6 color: palette.base @@ -45,10 +83,26 @@ Rectangle { spacing: 8 Text { - text: qsTr("Tool: %1").arg(root.toolName) + anchors.verticalCenter: parent.verticalCenter + text: root.statusMarker + textFormat: Text.PlainText + visible: text.length > 0 + font.pixelSize: 12 + color: root.statusColor + } + + Text { + id: headerTitle + + width: headerRow.width - x - 30 + text: root.toolKind.length > 0 + ? root.toolKind + ": " + root.headerName + : qsTr("Tool: %1").arg(root.headerName) + textFormat: Text.PlainText font.pixelSize: 13 font.bold: true - color: palette.text + color: root.hasFailed ? root.statusColor : palette.text + elide: Text.ElideRight } Text { @@ -60,8 +114,8 @@ Rectangle { } } - Column { - id: contentColumn + Loader { + id: contentLoader anchors { left: parent.left @@ -69,20 +123,88 @@ Rectangle { top: header.bottom margins: 10 } - spacing: 8 + active: root.expanded + sourceComponent: contentComponent + } - TextEdit { - id: resultText + Component { + id: contentComponent - width: parent.width - text: root.toolResult - readOnly: true - selectByMouse: true - color: palette.text - wrapMode: Text.WordWrap - font.family: "monospace" - font.pixelSize: 11 - selectionColor: palette.highlight + Column { + property alias resultEditor: resultEditorItem + + spacing: 8 + + Repeater { + model: root.locations + + delegate: Text { + id: locationEntry + + required property var modelData + + width: contentLoader.width + text: locationEntry.modelData.line !== undefined + ? "→ " + locationEntry.modelData.path + ":" + locationEntry.modelData.line + : "→ " + locationEntry.modelData.path + textFormat: Text.PlainText + color: palette.placeholderText + font.pixelSize: 11 + elide: Text.ElideMiddle + } + } + + Repeater { + model: root.diffs + + delegate: Column { + id: diffEntry + + required property var modelData + + width: contentLoader.width + spacing: 2 + + Text { + width: parent.width + text: qsTr("Diff") + ": " + (diffEntry.modelData.path || "") + textFormat: Text.PlainText + color: palette.text + font.pixelSize: 11 + font.bold: true + elide: Text.ElideMiddle + } + + TextEdit { + width: parent.width + text: (diffEntry.modelData.oldText || "") + "\n" + (diffEntry.modelData.newText || "") + textFormat: TextEdit.PlainText + readOnly: true + selectByMouse: true + color: palette.text + wrapMode: Text.WordWrap + font.family: "monospace" + font.pixelSize: 11 + selectionColor: palette.highlight + } + } + } + + TextEdit { + id: resultEditorItem + + width: parent.width + visible: text.length > 0 + text: root.resultText + textFormat: TextEdit.PlainText + readOnly: true + selectByMouse: true + color: palette.text + wrapMode: Text.WordWrap + font.family: "monospace" + font.pixelSize: 11 + selectionColor: palette.highlight + } } } @@ -98,14 +220,14 @@ Rectangle { Platform.MenuItem { text: qsTr("Copy") - enabled: resultText.selectedText.length > 0 - onTriggered: resultText.copy() + enabled: contentLoader.item && contentLoader.item.resultEditor.selectedText.length > 0 + onTriggered: contentLoader.item.resultEditor.copy() } Platform.MenuItem { text: qsTr("Select All") - enabled: resultText.text.length > 0 - onTriggered: resultText.selectAll() + enabled: contentLoader.item && contentLoader.item.resultEditor.text.length > 0 + onTriggered: contentLoader.item.resultEditor.selectAll() } Platform.MenuSeparator {} @@ -140,7 +262,7 @@ Rectangle { when: root.expanded PropertyChanges { target: root - implicitHeight: header.height + contentColumn.height + 20 + implicitHeight: header.height + contentLoader.height + 20 } } ] diff --git a/sources/ChatView/qml/controls/BottomBar.qml b/sources/ChatView/qml/controls/BottomBar.qml index e831054..8e8a048 100644 --- a/sources/ChatView/qml/controls/BottomBar.qml +++ b/sources/ChatView/qml/controls/BottomBar.qml @@ -12,15 +12,15 @@ Rectangle { id: root property alias sendButton: sendButtonId - property alias syncOpenFiles: syncOpenFilesId property alias attachFiles: attachFilesId property alias attachImages: attachImagesId - property alias linkFiles: linkFilesId property alias compressButton: compressButtonId + property alias compressTooltip: compressTooltipId property alias cancelCompressButton: cancelCompressButtonId property bool isCompressing: false property bool isProcessing: false + property bool agentMode: false property alias sendButtonTooltip: sendButtonTooltipId color: palette.window.hslLightness > 0.5 ? @@ -72,33 +72,6 @@ Rectangle { } } - QoAButton { - id: linkFilesId - - icon { - source: "qrc:/qt/qml/ChatView/icons/link-file-dark.svg" - height: 15 - width: 8 - } - - QoAToolTip { - visible: linkFilesId.hovered - delay: 250 - text: qsTr("Link file to context") - } - } - - CheckBox { - id: syncOpenFilesId - - text: qsTr("Sync open files") - - QoAToolTip { - visible: syncOpenFilesId.hovered - text: qsTr("Automatically synchronize currently opened files with the model context") - } - } - Item { Layout.fillWidth: true } @@ -119,7 +92,7 @@ Rectangle { } Text { - text: qsTr("Compressing...") + text: root.agentMode ? qsTr("Preparing summary...") : qsTr("Compressing...") anchors.verticalCenter: parent.verticalCenter color: palette.text font.pixelSize: 12 @@ -134,7 +107,7 @@ Rectangle { QoAToolTip { visible: cancelCompressButtonId.hovered delay: 250 - text: qsTr("Cancel compression") + text: root.agentMode ? qsTr("Cancel the summary") : qsTr("Cancel compression") } } } @@ -143,6 +116,7 @@ Rectangle { id: compressButtonId visible: !root.isCompressing + opacity: enabled ? 1.0 : 0.4 text: qsTr("Compress") icon { @@ -152,9 +126,10 @@ Rectangle { } QoAToolTip { + id: compressTooltipId + visible: compressButtonId.hovered delay: 250 - text: qsTr("Compress chat (create summarized copy using LLM)") } } diff --git a/sources/ChatView/qml/controls/ContextViewer.qml b/sources/ChatView/qml/controls/ContextViewer.qml index a57723b..a2245d0 100644 --- a/sources/ChatView/qml/controls/ContextViewer.qml +++ b/sources/ChatView/qml/controls/ContextViewer.qml @@ -14,18 +14,8 @@ Popup { id: root property string baseSystemPrompt - property string currentAgentRole - property string currentAgentRoleDescription - property string currentAgentRoleSystemPrompt - property var activeRules - property int activeRulesCount - property string selectedRuleContent signal openSettings() - signal openAgentRolesSettings() - signal openRulesFolder() - signal refreshRules() - signal ruleSelected(int index) modal: true focus: true @@ -59,11 +49,6 @@ Popup { Layout.fillWidth: true } - QoAButton { - text: qsTr("Refresh") - onClicked: root.refreshRules() - } - QoAButton { text: qsTr("Close") onClicked: root.close() @@ -159,281 +144,6 @@ Popup { } } } - - CollapsibleSection { - id: agentRoleSection - - Layout.fillWidth: true - title: qsTr("Agent Role") - badge: root.currentAgentRole - badgeColor: root.currentAgentRoleSystemPrompt.length > 0 ? Qt.rgba(0.3, 0.4, 0.7, 1.0) : palette.mid - - sectionContent: ColumnLayout { - spacing: 8 - - Text { - text: root.currentAgentRoleDescription - font.pixelSize: 11 - font.italic: true - color: palette.mid - wrapMode: Text.WordWrap - Layout.fillWidth: true - visible: root.currentAgentRoleDescription.length > 0 - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(Math.max(agentPromptText.implicitHeight + 16, 50), 200) - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - visible: root.currentAgentRoleSystemPrompt.length > 0 - - Flickable { - id: agentPromptFlickable - - anchors.fill: parent - anchors.margins: 8 - contentHeight: agentPromptText.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - TextEdit { - id: agentPromptText - - width: agentPromptFlickable.width - text: root.currentAgentRoleSystemPrompt - readOnly: true - selectByMouse: true - wrapMode: Text.WordWrap - color: palette.text - font.family: "monospace" - font.pixelSize: 11 - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: agentPromptFlickable.contentHeight > agentPromptFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - - Text { - text: qsTr("No role selected. Using base system prompt only.") - font.pixelSize: 11 - color: palette.mid - wrapMode: Text.WordWrap - Layout.fillWidth: true - visible: root.currentAgentRoleSystemPrompt.length === 0 - } - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - QoAButton { - text: qsTr("Copy") - enabled: root.currentAgentRoleSystemPrompt.length > 0 - onClicked: utils.copyToClipboard(root.currentAgentRoleSystemPrompt) - } - - QoAButton { - text: qsTr("Manage Roles") - onClicked: { - root.openAgentRolesSettings() - root.close() - } - } - } - } - } - - CollapsibleSection { - id: projectRulesSection - - Layout.fillWidth: true - title: qsTr("Project Rules") - badge: root.activeRulesCount > 0 ? qsTr("%1 active").arg(root.activeRulesCount) : qsTr("None") - badgeColor: root.activeRulesCount > 0 ? Qt.rgba(0.6, 0.5, 0.2, 1.0) : palette.mid - - sectionContent: ColumnLayout { - spacing: 8 - - SplitView { - Layout.fillWidth: true - Layout.preferredHeight: 220 - orientation: Qt.Horizontal - visible: root.activeRulesCount > 0 - - Rectangle { - SplitView.minimumWidth: 120 - SplitView.preferredWidth: 180 - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 5 - spacing: 5 - - Text { - text: qsTr("Rules (%1)").arg(rulesList.count) - font.pixelSize: 11 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - ListView { - id: rulesList - - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: root.activeRules - currentIndex: 0 - boundsBehavior: Flickable.StopAtBounds - - delegate: ItemDelegate { - required property var modelData - required property int index - - width: ListView.view.width - height: ruleItemContent.implicitHeight + 8 - highlighted: ListView.isCurrentItem - - background: Rectangle { - color: { - if (parent.highlighted) - return palette.highlight - if (parent.hovered) - return Qt.tint(palette.base, Qt.rgba(0, 0, 0, 0.05)) - return "transparent" - } - radius: 2 - } - - contentItem: ColumnLayout { - id: ruleItemContent - spacing: 2 - - Text { - text: modelData.fileName - font.pixelSize: 10 - color: parent.parent.highlighted ? palette.highlightedText : palette.text - elide: Text.ElideMiddle - Layout.fillWidth: true - } - - Text { - text: modelData.category - font.pixelSize: 9 - color: parent.parent.highlighted ? palette.highlightedText : palette.mid - Layout.fillWidth: true - } - } - - onClicked: { - rulesList.currentIndex = index - root.ruleSelected(index) - } - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: rulesList.contentHeight > rulesList.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - } - - Rectangle { - SplitView.fillWidth: true - SplitView.minimumWidth: 200 - color: palette.base - border.color: palette.mid - border.width: 1 - radius: 2 - - ColumnLayout { - anchors.fill: parent - anchors.margins: 5 - spacing: 5 - - RowLayout { - Layout.fillWidth: true - spacing: 5 - - Text { - text: qsTr("Content") - font.pixelSize: 11 - font.bold: true - color: palette.text - Layout.fillWidth: true - } - - QoAButton { - text: qsTr("Copy") - enabled: root.selectedRuleContent.length > 0 - onClicked: utils.copyToClipboard(root.selectedRuleContent) - } - } - - Flickable { - id: ruleContentFlickable - - Layout.fillWidth: true - Layout.fillHeight: true - contentHeight: ruleContentArea.implicitHeight - clip: true - boundsBehavior: Flickable.StopAtBounds - - TextEdit { - id: ruleContentArea - - width: ruleContentFlickable.width - text: root.selectedRuleContent - readOnly: true - selectByMouse: true - wrapMode: Text.WordWrap - selectionColor: palette.highlight - color: palette.text - font.family: "monospace" - font.pixelSize: 11 - } - - QQC.ScrollBar.vertical: QQC.ScrollBar { - policy: ruleContentFlickable.contentHeight > ruleContentFlickable.height ? QQC.ScrollBar.AsNeeded : QQC.ScrollBar.AlwaysOff - } - } - } - } - } - - Text { - text: qsTr("No project rules found.\nCreate .md files in .qodeassist/rules/common/ or .qodeassist/rules/chat/") - font.pixelSize: 11 - color: palette.mid - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - Layout.fillWidth: true - visible: root.activeRulesCount === 0 - } - - RowLayout { - Layout.fillWidth: true - - Item { Layout.fillWidth: true } - - QoAButton { - text: qsTr("Open Rules Folder") - onClicked: root.openRulesFolder() - } - } - } - } } QQC.ScrollBar.vertical: QQC.ScrollBar { @@ -448,7 +158,7 @@ Popup { } Text { - text: qsTr("Final prompt: Base System Prompt + Agent Role + Project Info + Project Rules + Linked Files") + text: qsTr("Final prompt: Base System Prompt + Project Info + Skills") font.pixelSize: 9 color: palette.mid wrapMode: Text.WordWrap @@ -534,10 +244,4 @@ Popup { active: sectionRoot.expanded } } - - onOpened: { - if (root.activeRulesCount > 0) { - root.ruleSelected(0) - } - } } diff --git a/sources/ChatView/qml/controls/DropZone.qml b/sources/ChatView/qml/controls/DropZone.qml new file mode 100644 index 0000000..5e3ffd7 --- /dev/null +++ b/sources/ChatView/qml/controls/DropZone.qml @@ -0,0 +1,148 @@ +// Copyright (C) 2025-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +import QtQuick +import QtQuick.Controls + +Item { + id: root + + signal filesDropped(var urlStrings) + + property int filesCount: 0 + property bool isDragActive: false + + Item { + id: dropOverlay + + anchors.fill: parent + visible: false + z: 999 + opacity: 0 + + Behavior on opacity { + NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } + } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6) + } + + Rectangle { + anchors { + top: parent.top + horizontalCenter: parent.horizontalCenter + topMargin: 30 + } + width: fileCountText.width + 40 + height: 50 + color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9) + radius: 25 + visible: root.filesCount > 0 + + Text { + id: fileCountText + anchors.centerIn: parent + text: qsTr("%n file(s) to drop", "", root.filesCount) + font.pixelSize: 16 + font.bold: true + color: palette.highlightedText + } + } + + Rectangle { + anchors.fill: parent + color: root.isDragActive + ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3) + : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15) + border.width: root.isDragActive ? 3 : 2 + border.color: root.isDragActive + ? palette.highlight + : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5) + + Column { + anchors.centerIn: parent + spacing: 15 + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Attach") + font.pixelSize: 24 + font.bold: true + color: root.isDragActive ? palette.highlightedText : palette.text + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Images & Text Files") + font.pixelSize: 14 + color: root.isDragActive ? palette.highlightedText : palette.text + opacity: 0.8 + } + } + + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.width { NumberAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } + } + } + + DropArea { + id: globalDropArea + + anchors.fill: parent + + onEntered: (drag) => { + if (drag.hasUrls) { + root.isDragActive = true + root.filesCount = drag.urls.length + dropOverlay.visible = true + dropOverlay.opacity = 1 + } + } + + onExited: { + root.isDragActive = false + root.filesCount = 0 + dropOverlay.opacity = 0 + + Qt.callLater(function() { + if (!root.isDragActive) { + dropOverlay.visible = false + } + }) + } + + onDropped: (drop) => { + root.isDragActive = false + root.filesCount = 0 + dropOverlay.opacity = 0 + + Qt.callLater(function() { + dropOverlay.visible = false + }) + + if (!drop.hasUrls || drop.urls.length === 0) { + return + } + + var urlStrings = [] + for (var i = 0; i < drop.urls.length; i++) { + var urlString = drop.urls[i].toString() + if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) { + urlStrings.push(urlString) + } + } + + if (urlStrings.length === 0) { + return + } + + drop.accept(Qt.CopyAction) + + root.filesDropped(urlStrings) + } + } +} diff --git a/sources/ChatView/qml/controls/SkillCommandPopup.qml b/sources/ChatView/qml/controls/SkillCommandPopup.qml index 78ccdd8..0f9371c 100644 --- a/sources/ChatView/qml/controls/SkillCommandPopup.qml +++ b/sources/ChatView/qml/controls/SkillCommandPopup.qml @@ -9,7 +9,6 @@ import QtQuick.Layouts Rectangle { id: root - // Object exposing Q_INVOKABLE QVariantList searchSkills(query). property var skillProvider: null property var searchResults: [] property int currentIndex: 0 @@ -25,7 +24,7 @@ Rectangle { radius: 4 function updateSearch(query) { - searchResults = skillProvider ? skillProvider.searchSkills(query) : [] + searchResults = skillProvider ? skillProvider.searchSlashCommands(query) : [] currentIndex = 0 } @@ -87,19 +86,55 @@ Rectangle { anchors.bottomMargin: 4 spacing: 1 - Text { + RowLayout { Layout.fillWidth: true - text: "/" + delegateItem.modelData.name - color: delegateItem.index === root.currentIndex - ? palette.highlightedText - : palette.text - font.bold: true - elide: Text.ElideRight + spacing: 6 + + Text { + Layout.fillWidth: true + text: "/" + delegateItem.modelData.name + textFormat: Text.PlainText + color: delegateItem.index === root.currentIndex + ? palette.highlightedText + : palette.text + font.bold: true + elide: Text.ElideRight + } + + Rectangle { + visible: sourceBadge.text.length > 0 + implicitWidth: Math.min(sourceBadge.implicitWidth + 10, 140) + implicitHeight: sourceBadge.implicitHeight + 2 + radius: height / 2 + color: delegateItem.index === root.currentIndex + ? Qt.rgba(palette.highlightedText.r, + palette.highlightedText.g, + palette.highlightedText.b, 0.2) + : palette.alternateBase + + Text { + id: sourceBadge + + anchors.fill: parent + anchors.leftMargin: 5 + anchors.rightMargin: 5 + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + text: delegateItem.modelData.source || "" + textFormat: Text.PlainText + elide: Text.ElideRight + color: delegateItem.index === root.currentIndex + ? palette.highlightedText + : palette.mid + font.pixelSize: 10 + } + } } Text { Layout.fillWidth: true text: delegateItem.modelData.description + textFormat: Text.PlainText color: delegateItem.index === root.currentIndex ? Qt.rgba(palette.highlightedText.r, palette.highlightedText.g, diff --git a/sources/ChatView/qml/controls/SplitDropZone.qml b/sources/ChatView/qml/controls/SplitDropZone.qml deleted file mode 100644 index a748194..0000000 --- a/sources/ChatView/qml/controls/SplitDropZone.qml +++ /dev/null @@ -1,276 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -import QtQuick -import QtQuick.Controls - -Item { - id: root - - signal filesDroppedToAttach(var urlStrings) - signal filesDroppedToLink(var urlStrings) - - property string activeZone: "" - property int filesCount: 0 - property bool isDragActive: false - - Item { - id: splitDropOverlay - - anchors.fill: parent - visible: false - z: 999 - opacity: 0 - - Behavior on opacity { - NumberAnimation { duration: 200; easing.type: Easing.InOutQuad } - } - - Rectangle { - anchors.fill: parent - color: Qt.rgba(palette.shadow.r, palette.shadow.g, palette.shadow.b, 0.6) - } - - Rectangle { - anchors { - top: parent.top - horizontalCenter: parent.horizontalCenter - topMargin: 30 - } - width: fileCountText.width + 40 - height: 50 - color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.9) - radius: 25 - visible: root.filesCount > 0 - - Text { - id: fileCountText - anchors.centerIn: parent - text: qsTr("%n file(s) to drop", "", root.filesCount) - font.pixelSize: 16 - font.bold: true - color: palette.highlightedText - } - } - - Rectangle { - id: leftZone - - anchors { - left: parent.left - top: parent.top - bottom: parent.bottom - } - width: parent.width / 2 - color: root.activeZone === "left" - ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3) - : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15) - border.width: root.activeZone === "left" ? 3 : 2 - border.color: root.activeZone === "left" - ? palette.highlight - : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5) - - Column { - anchors.centerIn: parent - spacing: 15 - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("Attach") - font.pixelSize: 24 - font.bold: true - color: root.activeZone === "left" ? palette.highlightedText : palette.text - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("Images & Text Files") - font.pixelSize: 14 - color: root.activeZone === "left" ? palette.highlightedText : palette.text - opacity: 0.8 - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("(for one-time use)") - font.pixelSize: 12 - font.italic: true - color: root.activeZone === "left" ? palette.highlightedText : palette.text - opacity: 0.6 - } - } - - Behavior on color { ColorAnimation { duration: 150 } } - Behavior on border.width { NumberAnimation { duration: 150 } } - Behavior on border.color { ColorAnimation { duration: 150 } } - } - - Rectangle { - id: rightZone - - anchors { - right: parent.right - top: parent.top - bottom: parent.bottom - } - width: parent.width / 2 - color: root.activeZone === "right" - ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3) - : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.15) - border.width: root.activeZone === "right" ? 3 : 2 - border.color: root.activeZone === "right" - ? palette.highlight - : Qt.rgba(palette.mid.r, palette.mid.g, palette.mid.b, 0.5) - - Column { - anchors.centerIn: parent - spacing: 15 - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("LINK") - font.pixelSize: 24 - font.bold: true - color: root.activeZone === "right" ? palette.highlightedText : palette.text - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("Text Files") - font.pixelSize: 14 - color: root.activeZone === "right" ? palette.highlightedText : palette.text - opacity: 0.8 - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: qsTr("(added to context)") - font.pixelSize: 12 - font.italic: true - color: root.activeZone === "right" ? palette.highlightedText : palette.text - opacity: 0.6 - } - } - - Behavior on color { ColorAnimation { duration: 150 } } - Behavior on border.width { NumberAnimation { duration: 150 } } - Behavior on border.color { ColorAnimation { duration: 150 } } - } - - Rectangle { - anchors { - horizontalCenter: parent.horizontalCenter - top: parent.top - bottom: parent.bottom - } - width: 2 - color: palette.mid - opacity: 0.4 - } - - MouseArea { - id: leftDropArea - - anchors { - left: parent.left - top: parent.top - bottom: parent.bottom - } - width: parent.width / 2 - hoverEnabled: true - - onEntered: { - root.activeZone = "left" - } - } - - MouseArea { - id: rightDropArea - - anchors { - right: parent.right - top: parent.top - bottom: parent.bottom - } - width: parent.width / 2 - hoverEnabled: true - - onEntered: { - root.activeZone = "right" - } - } - } - - DropArea { - id: globalDropArea - - anchors.fill: parent - - onEntered: (drag) => { - if (drag.hasUrls) { - root.isDragActive = true - root.filesCount = drag.urls.length - splitDropOverlay.visible = true - splitDropOverlay.opacity = 1 - root.activeZone = "" - } - } - - onExited: { - root.isDragActive = false - root.filesCount = 0 - splitDropOverlay.opacity = 0 - - Qt.callLater(function() { - if (!root.isDragActive) { - splitDropOverlay.visible = false - root.activeZone = "" - } - }) - } - - onPositionChanged: (drag) => { - if (drag.hasUrls) { - root.activeZone = drag.x < globalDropArea.width / 2 ? "left" : "right" - } - } - - onDropped: (drop) => { - const targetZone = root.activeZone - root.isDragActive = false - root.filesCount = 0 - splitDropOverlay.opacity = 0 - - Qt.callLater(function() { - splitDropOverlay.visible = false - root.activeZone = "" - }) - - if (!drop.hasUrls || drop.urls.length === 0) { - return - } - - var urlStrings = [] - for (var i = 0; i < drop.urls.length; i++) { - var urlString = drop.urls[i].toString() - if (urlString.startsWith("file://") || urlString.indexOf("://") === -1) { - urlStrings.push(urlString) - } - } - - if (urlStrings.length === 0) { - return - } - - drop.accept(Qt.CopyAction) - - if (targetZone === "right") { - root.filesDroppedToLink(urlStrings) - } else { - root.filesDroppedToAttach(urlStrings) - } - } - } -} - diff --git a/sources/ChatView/qml/controls/TopBar.qml b/sources/ChatView/qml/controls/TopBar.qml index efe792e..e605f41 100644 --- a/sources/ChatView/qml/controls/TopBar.qml +++ b/sources/ChatView/qml/controls/TopBar.qml @@ -23,11 +23,8 @@ Rectangle { property alias pinButton: pinButtonId property alias relocateButton: relocateButtonId property alias contextButton: contextButtonId - property alias toolsButton: toolsButtonId - property alias thinkingMode: thinkingModeId property alias settingsButton: settingsButtonId property alias configSelector: configSelectorId - property alias roleSelector: roleSelector property alias relocateTooltip: relocateTooltipId color: palette.window.hslLightness > 0.5 ? @@ -147,82 +144,11 @@ Rectangle { text: qsTr("Switch saved AI configuration") } } - - QoAComboBox { - id: roleSelector - - implicitHeight: 25 - - model: [] - currentIndex: 0 - - QoAToolTip { - visible: roleSelector.hovered - delay: 250 - text: qsTr("Switch agent role (different system prompts)") - } - } } Row { spacing: 10 - QoAButton { - id: toolsButtonId - - anchors.verticalCenter: parent.verticalCenter - - checkable: true - opacity: enabled ? 1.0 : 0.2 - - icon { - source: checked ? "qrc:/qt/qml/ChatView/icons/tools-icon-on.svg" - : "qrc:/qt/qml/ChatView/icons/tools-icon-off.svg" - color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" - height: 15 - width: 15 - } - - QoAToolTip { - visible: toolsButtonId.hovered - delay: 250 - text: { - if (!toolsButtonId.enabled) { - return qsTr("Tools are disabled in General Settings") - } - return toolsButtonId.checked - ? qsTr("Tools enabled: AI can use tools to read files, search project, and build code") - : qsTr("Tools disabled: Simple conversation without tool access") - } - } - } - - QoAButton { - id: thinkingModeId - - anchors.verticalCenter: parent.verticalCenter - - checkable: true - opacity: enabled ? 1.0 : 0.2 - - icon { - source: checked ? "qrc:/qt/qml/ChatView/icons/thinking-icon-on.svg" - : "qrc:/qt/qml/ChatView/icons/thinking-icon-off.svg" - color: palette.window.hslLightness > 0.5 ? "#000000" : "#FFFFFF" - height: 15 - width: 15 - } - - QoAToolTip { - visible: thinkingModeId.hovered - delay: 250 - text: thinkingModeId.enabled - ? (thinkingModeId.checked ? qsTr("Thinking Mode enabled (Check model list support it)") - : qsTr("Thinking Mode disabled")) - : qsTr("Thinking Mode is not available for this provider") - } - } - QoAButton { id: settingsButtonId @@ -345,7 +271,7 @@ Rectangle { QoAToolTip { visible: contextButtonId.hovered delay: 250 - text: qsTr("View chat context (system prompt, role, rules)") + text: qsTr("View chat context (system prompt)") } } diff --git a/sources/acp/AcpChatBackend.cpp b/sources/acp/AcpChatBackend.cpp new file mode 100644 index 0000000..d50fdd7 --- /dev/null +++ b/sources/acp/AcpChatBackend.cpp @@ -0,0 +1,1067 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AcpChatBackend.hpp" + +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace QodeAssist::Acp { + +namespace { + +constexpr int authRequiredErrorCode = -32000; +constexpr int maxStderrLines = 20; +constexpr qsizetype maxInlineAttachmentBytes = 512 * 1024; +constexpr qsizetype maxInlineImageBytes = 8 * 1024 * 1024; +constexpr qsizetype maxPermissionOptions = 16; +constexpr qsizetype maxPermissionTitleLength = 300; +constexpr qsizetype maxPermissionKindLength = 40; +constexpr qsizetype maxPermissionOptionNameLength = 120; +constexpr qsizetype maxAgentCommands = 128; +constexpr qsizetype maxAgentCommandNameLength = 64; +constexpr qsizetype maxToolResultLength = 64 * 1024; +constexpr qsizetype maxToolDiffLength = 64 * 1024; +constexpr qsizetype maxToolLocations = 64; +constexpr qsizetype maxToolDiffs = 16; +constexpr qsizetype maxAgentTitleLength = 60; + +QString clampAgentText(const QString &text, qsizetype limit) +{ + if (text.size() <= limit) + return text; + return text.first(limit - 1) + QChar(0x2026); +} + +QString truncationNotice(qsizetype shown, qsizetype total) +{ + return QLatin1Char('\n') + + QObject::tr("[truncated by QodeAssist: %1 of %2 bytes shown]").arg(shown).arg(total); +} + +QString clampAgentBody(const QString &text, qsizetype limit) +{ + if (text.size() <= limit) + return text; + return text.first(limit) + truncationNotice(limit, text.size()); +} + +QString singleLineAgentText(const QString &text, qsizetype limit) +{ + QString line; + line.reserve(qMin(text.size(), limit)); + + for (const QChar c : text) { + if (c.isSpace()) { + if (!line.isEmpty() && !line.endsWith(QLatin1Char(' '))) + line.append(QLatin1Char(' ')); + continue; + } + if (c.category() == QChar::Other_Control || c.category() == QChar::Other_Format) + continue; + line.append(c); + } + + return clampAgentText(line.trimmed(), limit); +} + +QString validatePermissionOptions(const QList &options) +{ + if (options.isEmpty()) + return QStringLiteral("the agent offered no options to choose from"); + + if (options.size() > maxPermissionOptions) { + return QStringLiteral("the agent offered %1 options, more than the %2 allowed") + .arg(options.size()) + .arg(maxPermissionOptions); + } + + QSet seen; + for (const LLMQore::Acp::PermissionOption &option : options) { + if (option.optionId.isEmpty()) + return QStringLiteral("the agent offered an option with no id"); + if (seen.contains(option.optionId)) + return QStringLiteral("the agent offered option id %1 twice").arg(option.optionId); + seen.insert(option.optionId); + } + + return {}; +} + +QString attachmentUri(const QString &fileName) +{ + return QStringLiteral("qodeassist://attachment/") + + QString::fromUtf8(QUrl::toPercentEncoding(fileName)); +} + +QMimeType mimeTypeForFileName(const QString &fileName) +{ + return QMimeDatabase().mimeTypeForFile(fileName, QMimeDatabase::MatchExtension); +} + +QString textMimeTypeForFileName(const QString &fileName) +{ + const QMimeType type = mimeTypeForFileName(fileName); + return type.inherits(QStringLiteral("text/plain")) ? type.name() : QStringLiteral("text/plain"); +} + +LLMQore::Acp::ContentBlock makeResource( + const QString &uri, const QString &mimeType, const QString &text) +{ + LLMQore::Acp::EmbeddedResource resource; + resource.uri = uri; + resource.mimeType = mimeType; + resource.text = text; + + LLMQore::Acp::ContentBlock block; + block.type = QStringLiteral("resource"); + block.resource = resource; + return block; +} + +LLMQore::Acp::ContentBlock makeImage(const QString &mediaType, const QString &base64Data) +{ + LLMQore::Acp::ContentBlock block; + block.type = QStringLiteral("image"); + block.mimeType = mediaType; + block.data = base64Data; + return block; +} + +bool hasSendableContent(const QList &userBlocks) +{ + for (const Session::ContentBlock &block : userBlocks) { + if (const auto *text = std::get_if(&block)) { + if (!text->text.isEmpty()) + return true; + continue; + } + if (std::get_if(&block) + || std::get_if(&block)) { + return true; + } + } + return false; +} + +QString textOf(const LLMQore::Acp::ContentBlock &block) +{ + return block.type == QLatin1String("text") ? block.text : QString(); +} + +int tokenCount(const QJsonValue &value) +{ + qint64 count = -1; + + if (value.isDouble()) { + const double raw = value.toDouble(-1.0); + if (raw >= 0.0 && raw == std::floor(raw)) + count = static_cast(raw); + } else if (value.isString()) { + bool parsed = false; + const qint64 raw = value.toString().toLongLong(&parsed); + if (parsed && raw >= 0) + count = raw; + } + + if (count < 0) + return 0; + + return static_cast(qMin(count, std::numeric_limits::max())); +} + +int usageValue(const QJsonObject &usage, QLatin1StringView camelCase, QLatin1StringView snakeCase) +{ + const int fromCamelCase = tokenCount(usage.value(camelCase)); + return fromCamelCase > 0 ? fromCamelCase : tokenCount(usage.value(snakeCase)); +} + +Session::Usage usageFromPromptResult(const QJsonObject &usage) +{ + return Session::Usage{ + .promptTokens = usageValue(usage, QLatin1StringView("inputTokens"), + QLatin1StringView("input_tokens")), + .completionTokens = usageValue(usage, QLatin1StringView("outputTokens"), + QLatin1StringView("output_tokens")), + .cachedPromptTokens = usageValue(usage, QLatin1StringView("cachedReadTokens"), + QLatin1StringView("cached_read_tokens")), + .reasoningTokens = 0}; +} + +QString describeContentBlock(const LLMQore::Acp::ContentBlock &block) +{ + if (block.type == QLatin1String("text")) + return block.text; + + if (block.type == QLatin1String("resource") && block.resource) { + return block.resource->text.isEmpty() + ? QObject::tr("[resource %1]").arg(block.resource->uri) + : block.resource->text; + } + + if (block.type == QLatin1String("resource_link")) + return QObject::tr("[link %1]").arg(block.uri); + + if (block.type == QLatin1String("image") || block.type == QLatin1String("audio")) + return QObject::tr("[%1 result, not shown]").arg(block.type); + + LOG_MESSAGE(QString("Unhandled ACP tool result content type: %1").arg(block.type)); + return QObject::tr("[%1 result, not shown]").arg(block.type); +} + +QString toolResultText(const LLMQore::Acp::ToolCall &toolCall) +{ + QStringList parts; + + for (const LLMQore::Acp::ToolCallContent &content : toolCall.content) { + if (content.type == QLatin1String("content")) { + if (content.content) + parts.append(describeContentBlock(*content.content)); + } else if (content.type == QLatin1String("terminal")) { + parts.append(QObject::tr("Terminal %1").arg(content.terminalId)); + } else if (content.type != QLatin1String("diff")) { + LOG_MESSAGE(QString("Unhandled ACP tool call content type: %1").arg(content.type)); + } + } + + parts.removeAll(QString()); + return clampAgentBody(parts.join(QLatin1Char('\n')), maxToolResultLength); +} + +QJsonObject toolDetailsOf(const LLMQore::Acp::ToolCall &toolCall) +{ + QJsonArray locations; + for (const LLMQore::Acp::ToolCallLocation &location : toolCall.locations) { + if (locations.size() >= maxToolLocations) { + LOG_MESSAGE(QString("Dropping %1 extra locations reported by tool call %2") + .arg(toolCall.locations.size() - maxToolLocations) + .arg(toolCall.toolCallId)); + break; + } + QJsonObject entry{{"path", singleLineAgentText(location.path, maxPermissionTitleLength)}}; + if (location.line) + entry["line"] = *location.line; + locations.append(entry); + } + + QJsonArray diffs; + for (const LLMQore::Acp::ToolCallContent &content : toolCall.content) { + if (content.type != QLatin1String("diff")) + continue; + if (diffs.size() >= maxToolDiffs) { + LOG_MESSAGE( + QString("Dropping extra diffs reported by tool call %1").arg(toolCall.toolCallId)); + break; + } + QJsonObject diff{ + {"path", singleLineAgentText(content.path, maxPermissionTitleLength)}, + {"oldText", clampAgentBody(content.oldText, maxToolDiffLength)}, + {"newText", clampAgentBody(content.newText, maxToolDiffLength)}}; + if (content.oldText.size() > maxToolDiffLength + || content.newText.size() > maxToolDiffLength) { + diff["truncated"] = true; + } + diffs.append(diff); + } + + QJsonObject details; + if (!locations.isEmpty()) + details["locations"] = locations; + if (!diffs.isEmpty()) + details["diffs"] = diffs; + return details; +} + +} // namespace + +AcpChatBackend::AcpChatBackend(QObject *parent) + : Session::ChatBackend(parent) + , m_clientFactory(&spawnAgent) + , m_permissions(new ChatPermissionProvider(&m_ledger, this)) +{ + m_permissions->setRequestHandler( + [this]( + const QString &requestId, + const LLMQore::Acp::ToolCall &toolCall, + const QList &options) { + requestPermission(requestId, toolCall, options); + }); +} + +void AcpChatBackend::setClientFactory(ClientFactory factory) +{ + m_clientFactory = std::move(factory); +} + +void AcpChatBackend::setStoredContentLoader(StoredContentLoader loader) +{ + m_storedContentLoader = std::move(loader); +} + +void AcpChatBackend::setKnowledgeService(AgentKnowledgeService *service) +{ + m_knowledgeService = service; +} + +QList AcpChatBackend::knowledgeServers() +{ + if (!m_knowledgeService) + return {}; + + if (!m_agentInfo.agentCapabilities.mcpCapabilities.http) { + LOG_MESSAGE( + QString("Agent %1 does not accept HTTP MCP servers, so QodeAssist knowledge is not " + "offered") + .arg(m_agent ? m_agent->id : QString())); + return {}; + } + + const QString url = m_knowledgeService->start(); + if (url.isEmpty()) { + LOG_MESSAGE("The QodeAssist knowledge server did not start, continuing without it"); + return {}; + } + + m_knowledgeServerRunning = true; + LOG_MESSAGE(QString("Offering the agent the QodeAssist knowledge server at %1").arg(url)); + + return {LLMQore::Acp::McpServer::http(m_knowledgeService->serverName(), url)}; +} + +void AcpChatBackend::stopKnowledgeServer() +{ + if (!m_knowledgeServerRunning) + return; + + m_knowledgeServerRunning = false; + if (m_knowledgeService) + m_knowledgeService->stop(); +} + +void AcpChatBackend::setChatFilePath(const QString &filePath) +{ + m_chatFilePath = filePath; +} + +void AcpChatBackend::bindAgent(const AgentDefinition &agent) +{ + if (m_agent && m_agent->id == agent.id) + return; + + cancel(); + releaseClient(); + + m_agent = agent; + m_sessionId.clear(); + m_resumeSessionId.clear(); + m_handoverSummary.clear(); + m_authenticated = false; +} + +QString AcpChatBackend::boundAgentId() const +{ + return m_agent ? m_agent->id : QString(); +} + +void AcpChatBackend::adoptEarlyCommands() +{ + if (!m_earlyCommandsSessionId.isEmpty() && m_earlyCommandsSessionId == m_sessionId) { + m_availableCommands = m_earlyCommands; + emit availableCommandsChanged(); + } + m_earlyCommands.clear(); + m_earlyCommandsSessionId.clear(); + + if (!m_earlyTitleSessionId.isEmpty() && m_earlyTitleSessionId == m_sessionId + && !m_earlyTitle.isEmpty()) { + emit sessionEvent(Session::SessionInfo{.title = m_earlyTitle}); + } + m_earlyTitle.clear(); + m_earlyTitleSessionId.clear(); +} + +QString AcpChatBackend::boundAgentName() const +{ + return m_agent ? singleLineAgentText(m_agent->name, maxPermissionKindLength) : QString(); +} + +void AcpChatBackend::sendTurn(const Session::TurnRequest &request) +{ + if (!m_agent) { + emit sessionEvent( + Session::TurnFailed{.turnId = {}, .error = tr("No agent is bound to this chat.")}); + return; + } + + cancel(); + + if (!hasSendableContent(request.userBlocks)) { + emit sessionEvent( + Session::TurnFailed{.turnId = {}, .error = tr("There is nothing to send to the agent.")}); + return; + } + + m_pendingTurn = PendingTurn{request.userBlocks}; + + const QString turnId = m_ledger.beginTurn(QStringLiteral("acp-%1").arg(++m_turnCounter)); + m_stderr.clear(); + emit sessionEvent(Session::TurnStarted{.turnId = turnId}); + + if (!m_client) { + startClient(); + return; + } + + if (m_sessionId.isEmpty()) { + if (!m_establishingSession) + resumeOrStartSession(); + return; + } + + sendPrompt(); +} + +void AcpChatBackend::startClient() +{ + m_workingDirectory = agentWorkingDirectory(); + + const AgentProcess process = m_clientFactory(*m_agent, m_workingDirectory, this); + m_client = process.client; + m_runner = process.command; + + if (!m_client) { + failTurn(tr("This agent has no launchable distribution."), true); + return; + } + + m_client->setPermissionProvider(m_permissions); + connectClient(); + + const int generation = m_clientGeneration; + + m_client->connectAndInitialize(std::chrono::seconds(60)) + .then( + this, + [this, generation](const LLMQore::Acp::InitializeResult &result) { + if (generation != m_clientGeneration) + return; + m_agentInfo = result; + if (m_ledger.hasActiveTurn()) + resumeOrStartSession(); + }) + .onFailed(this, [this, generation](const std::exception &e) { + if (generation != m_clientGeneration) + return; + failTurn(QString::fromUtf8(e.what()), true); + }); +} + +void AcpChatBackend::resumeSession(const QString &sessionId) +{ + m_resumeSessionId = sessionId; +} + +void AcpChatBackend::startFreshSession() +{ + m_resumeSessionId.clear(); +} + +void AcpChatBackend::setHandoverSummary(const QString &summary) +{ + m_handoverSummary = summary; +} + +void AcpChatBackend::resumeOrStartSession() +{ + if (m_resumeSessionId.isEmpty()) { + startSession(); + return; + } + + const QString resumed = m_resumeSessionId; + + if (!m_agentInfo.agentCapabilities.loadSession) { + m_resumeSessionId.clear(); + failTurn( + tr("This agent cannot reopen a previous conversation, so the transcript below is " + "read-only. Start a new session to continue with this agent."), + false); + emit agentSessionUnavailable( + tr("%1 does not support reopening a session.") + .arg(m_agent ? m_agent->name : tr("This agent"))); + return; + } + + LLMQore::Acp::LoadSessionParams params; + params.sessionId = resumed; + params.cwd = m_workingDirectory; + params.mcpServers = knowledgeServers(); + + const int generation = m_clientGeneration; + m_establishingSession = true; + + m_client->loadSession(params, std::chrono::seconds(60)) + .then( + this, + [this, resumed, generation](const LLMQore::Acp::NewSessionResult &result) { + if (generation != m_clientGeneration) + return; + m_establishingSession = false; + m_resumeSessionId.clear(); + m_sessionId = result.sessionId.isEmpty() ? resumed : result.sessionId; + adoptEarlyCommands(); + if (m_ledger.hasActiveTurn()) + sendPrompt(); + }) + .onFailed(this, [this, generation](const std::exception &e) { + if (generation != m_clientGeneration) + return; + m_establishingSession = false; + m_resumeSessionId.clear(); + stopKnowledgeServer(); + const QString reason = QString::fromUtf8(e.what()); + LOG_MESSAGE(QString("Could not reopen the agent session: %1").arg(reason)); + failTurn( + tr("The previous agent session could not be reopened, so the transcript below is " + "read-only. Start a new session to continue with this agent."), + false); + emit agentSessionUnavailable(reason); + }); +} + +void AcpChatBackend::startSession() +{ + LLMQore::Acp::NewSessionParams params; + params.cwd = m_workingDirectory; + params.mcpServers = knowledgeServers(); + + const int generation = m_clientGeneration; + m_establishingSession = true; + + m_client->newSession(params, std::chrono::seconds(60)) + .then( + this, + [this, generation](const LLMQore::Acp::NewSessionResult &result) { + if (generation != m_clientGeneration) + return; + m_establishingSession = false; + m_sessionId = result.sessionId; + adoptEarlyCommands(); + if (m_ledger.hasActiveTurn()) + sendPrompt(); + }) + .onFailed( + this, + [this, generation](const LLMQore::Rpc::RemoteError &error) { + if (generation != m_clientGeneration) + return; + m_establishingSession = false; + const bool needsAuthentication = error.code() == authRequiredErrorCode + && !m_authenticated + && !m_agentInfo.authMethods.isEmpty(); + if (needsAuthentication) + authenticateAndRetry(); + else + failTurn(error.remoteMessage(), true); + }) + .onFailed(this, [this, generation](const std::exception &e) { + if (generation != m_clientGeneration) + return; + m_establishingSession = false; + failTurn(QString::fromUtf8(e.what()), true); + }); +} + +void AcpChatBackend::authenticateAndRetry() +{ + const LLMQore::Acp::AuthMethod method = m_agentInfo.authMethods.first(); + LOG_MESSAGE(QString("ACP agent requires authentication, using method: %1").arg(method.id)); + + m_authenticated = true; + + const int generation = m_clientGeneration; + const QString turnId = m_ledger.activeTurnId(); + + m_client->authenticate(method.id, std::chrono::minutes(5)) + .then( + this, + [this, generation, turnId]() { + if (generation != m_clientGeneration || !m_ledger.isActiveTurn(turnId)) + return; + resumeOrStartSession(); + }) + .onFailed(this, [this, generation, turnId, method](const std::exception &e) { + if (generation != m_clientGeneration || !m_ledger.isActiveTurn(turnId)) + return; + failTurn( + tr("Authentication (%1) failed: %2") + .arg(method.name.isEmpty() ? method.id : method.name) + .arg(QString::fromUtf8(e.what())), + true); + }); +} + +QList AcpChatBackend::buildPrompt() const +{ + QList blocks; + + if (!m_handoverSummary.isEmpty()) { + blocks.append( + LLMQore::Acp::ContentBlock::makeText( + tr("This conversation continues an earlier session you do not have. Here is a " + "summary of what happened before, prepared by QodeAssist:\n\n%1") + .arg(m_handoverSummary))); + } + + for (const Session::ContentBlock &block : m_pendingTurn.userBlocks) { + if (const auto *text = std::get_if(&block)) { + if (!text->text.isEmpty()) + blocks.append(LLMQore::Acp::ContentBlock::makeText(text->text)); + } else if (const auto *attachment = std::get_if(&block)) { + appendAttachment(blocks, *attachment); + } else if (const auto *image = std::get_if(&block)) { + appendImage(blocks, *image); + } + } + + return blocks; +} + +void AcpChatBackend::appendAttachment( + QList &blocks, const Session::AttachmentBlock &attachment) const +{ + const QByteArray content = m_storedContentLoader + ? m_storedContentLoader(m_chatFilePath, attachment.storedPath) + : QByteArray(); + if (content.isEmpty()) { + LOG_MESSAGE(QString("Could not load attachment %1 for the agent").arg(attachment.fileName)); + blocks.append( + LLMQore::Acp::ContentBlock::makeText( + tr("The user attached %1, but it has no readable content.") + .arg(attachment.fileName))); + return; + } + + QString text = QString::fromUtf8(content.first(qMin(content.size(), maxInlineAttachmentBytes))); + if (content.size() > maxInlineAttachmentBytes) { + LOG_MESSAGE(QString("Truncating attachment %1 from %2 to %3 bytes for the agent") + .arg(attachment.fileName) + .arg(content.size()) + .arg(maxInlineAttachmentBytes)); + text += QLatin1Char('\n') + + tr("[truncated by QodeAssist: %1 of %2 bytes shown]") + .arg(maxInlineAttachmentBytes) + .arg(content.size()); + } + + if (m_agentInfo.agentCapabilities.promptCapabilities.embeddedContext) { + blocks.append( + makeResource( + attachmentUri(attachment.fileName), + textMimeTypeForFileName(attachment.fileName), + text)); + return; + } + + blocks.append( + LLMQore::Acp::ContentBlock::makeText( + Session::fencedFileBlock(attachment.fileName, text))); +} + +void AcpChatBackend::appendImage( + QList &blocks, const Session::ImageBlock &image) const +{ + if (!m_agentInfo.agentCapabilities.promptCapabilities.image) { + blocks.append( + LLMQore::Acp::ContentBlock::makeText( + tr("The user attached the image %1, which this agent cannot receive.") + .arg(image.fileName))); + return; + } + + const QByteArray content = m_storedContentLoader + ? m_storedContentLoader(m_chatFilePath, image.storedPath) + : QByteArray(); + if (content.isEmpty()) { + LOG_MESSAGE(QString("Could not load image %1 for the agent").arg(image.fileName)); + blocks.append( + LLMQore::Acp::ContentBlock::makeText( + tr("The user attached the image %1, but it has no readable content.") + .arg(image.fileName))); + return; + } + + if (content.size() > maxInlineImageBytes) { + LOG_MESSAGE(QString("Image %1 is %2 bytes, over the %3 byte limit for an agent prompt") + .arg(image.fileName) + .arg(content.size()) + .arg(maxInlineImageBytes)); + blocks.append( + LLMQore::Acp::ContentBlock::makeText( + tr("The user attached the image %1, which is too large to send (%2 bytes).") + .arg(image.fileName) + .arg(content.size()))); + return; + } + + blocks.append(makeImage(image.mediaType, QString::fromLatin1(content.toBase64()))); +} + +void AcpChatBackend::sendPrompt() +{ + const QString turnId = m_ledger.activeTurnId(); + + m_client->prompt(m_sessionId, buildPrompt(), std::chrono::minutes(30)) + .then( + this, + [this, turnId](const LLMQore::Acp::PromptResult &result) { + if (!m_ledger.isActiveTurn(turnId)) + return; + + m_handoverSummary.clear(); + + const Session::Usage usage = usageFromPromptResult(result.usage); + if (!usage.isEmpty()) + emit sessionEvent( + Session::UsageReported{.turnId = turnId, .usage = usage}); + + m_pendingTurn = {}; + finishTurn(turnId); + emit sessionEvent(Session::TurnCompleted{.turnId = turnId}); + }) + .onFailed(this, [this, turnId](const std::exception &e) { + if (!m_ledger.isActiveTurn(turnId)) + return; + failTurn(QString::fromUtf8(e.what()), false); + }); +} + +void AcpChatBackend::connectClient() +{ + connect( + m_client, + &LLMQore::Acp::AcpClient::agentMessageChunk, + this, + [this](const QString &, const LLMQore::Acp::ContentBlock &content) { + const QString text = textOf(content); + if (!m_ledger.hasActiveTurn() || text.isEmpty()) + return; + emit sessionEvent(Session::TextDelta{.turnId = m_ledger.activeTurnId(), .text = text}); + }); + + connect( + m_client, + &LLMQore::Acp::AcpClient::agentThoughtChunk, + this, + [this](const QString &, const LLMQore::Acp::ContentBlock &content) { + const QString text = textOf(content); + if (!m_ledger.hasActiveTurn() || text.isEmpty()) + return; + emit sessionEvent( + Session::ThinkingReceived{.turnId = m_ledger.activeTurnId(), .text = text}); + }); + + const auto onToolCall + = [this](const QString &sessionId, const LLMQore::Acp::ToolCall &toolCall) { + if (!m_ledger.hasActiveTurn() || sessionId != m_sessionId) + return; + emit sessionEvent( + Session::ToolCallUpdated{ + .turnId = m_ledger.activeTurnId(), + .toolId = singleLineAgentText(toolCall.toolCallId, maxPermissionTitleLength), + .name = singleLineAgentText(toolCall.title, maxPermissionTitleLength), + .kind = singleLineAgentText(toolCall.kind, maxPermissionKindLength), + .status = singleLineAgentText(toolCall.status, maxPermissionKindLength), + .result = toolResultText(toolCall), + .details = toolDetailsOf(toolCall), + .fromAgent = true}); + }; + + connect(m_client, &LLMQore::Acp::AcpClient::toolCallStarted, this, onToolCall); + connect(m_client, &LLMQore::Acp::AcpClient::toolCallUpdated, this, onToolCall); + + connect( + m_client, + &LLMQore::Acp::AcpClient::planUpdated, + this, + [this](const QString &, const LLMQore::Acp::Plan &plan) { + if (!m_ledger.hasActiveTurn()) + return; + + QList entries; + entries.reserve(plan.entries.size()); + for (const LLMQore::Acp::PlanEntry &entry : plan.entries) + entries.append(Session::PlanEntry{entry.content, entry.priority, entry.status}); + + emit sessionEvent( + Session::PlanUpdated{.turnId = m_ledger.activeTurnId(), .entries = entries}); + }); + + connect( + m_client, + &LLMQore::Acp::AcpClient::sessionInfoUpdated, + this, + [this](const QString &sessionId, const QString &title) { + const QString suggested = singleLineAgentText(title, maxAgentTitleLength); + if (suggested.isEmpty()) + return; + + if (sessionId == m_sessionId) { + emit sessionEvent(Session::SessionInfo{.title = suggested}); + return; + } + + if (m_establishingSession) { + m_earlyTitleSessionId = sessionId; + m_earlyTitle = suggested; + } + }); + + connect( + m_client, + &LLMQore::Acp::AcpClient::availableCommandsUpdated, + this, + [this](const QString &sessionId, const QList &commands) { + QList clamped; + clamped.reserve(qMin(commands.size(), maxAgentCommands)); + for (const LLMQore::Acp::AvailableCommand &command : commands) { + if (clamped.size() >= maxAgentCommands) { + LOG_MESSAGE( + QString("Dropping %1 extra agent slash commands") + .arg(commands.size() - maxAgentCommands)); + break; + } + static const QRegularExpression commandNamePattern( + QStringLiteral("^[A-Za-z0-9._:-]{1,64}$")); + if (!commandNamePattern.match(command.name).hasMatch()) { + LOG_MESSAGE( + QString("Skipping agent slash command with an unusable name: %1") + .arg(singleLineAgentText(command.name, maxAgentCommandNameLength))); + continue; + } + + LLMQore::Acp::AvailableCommand entry; + entry.name = command.name; + entry.description + = singleLineAgentText(command.description, maxPermissionTitleLength); + entry.inputHint = singleLineAgentText(command.inputHint, maxPermissionTitleLength); + clamped.append(entry); + } + + if (sessionId == m_sessionId) { + m_availableCommands = clamped; + emit availableCommandsChanged(); + return; + } + + if (m_establishingSession) { + m_earlyCommandsSessionId = sessionId; + m_earlyCommands = clamped; + } + }); + + connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this](const QString &line) { + if (m_stderr.size() < maxStderrLines) + m_stderr.append(line); + }); + + connect(m_client, &LLMQore::Acp::AcpClient::disconnected, this, [this] { + if (!m_ledger.hasActiveTurn()) + releaseClient(); + }); + + connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this](const QString &error) { + if (!m_ledger.hasActiveTurn()) { + LOG_MESSAGE(QString("Agent reported an error outside a turn: %1").arg(error)); + releaseClient(); + return; + } + failTurn(error, true); + }); +} + +void AcpChatBackend::requestPermission( + const QString &requestId, + const LLMQore::Acp::ToolCall &toolCall, + const QList &options) +{ + if (!m_ledger.hasActiveTurn()) { + LOG_MESSAGE( + QString("Cancelling agent permission request %1 that arrived outside a turn") + .arg(requestId)); + m_ledger.cancelPermission(requestId); + return; + } + + const QString turnId = m_ledger.activeTurnId(); + const QString title = clampAgentText(toolCall.title, maxPermissionTitleLength); + + QList offered; + const QString rejection = validatePermissionOptions(options); + + if (rejection.isEmpty()) { + offered.reserve(options.size()); + for (const LLMQore::Acp::PermissionOption &option : options) { + offered.append( + Session::PermissionOption{ + option.optionId, + clampAgentText(option.name, maxPermissionOptionNameLength), + option.kind}); + } + } else { + LOG_MESSAGE( + QString("Declining agent permission request %1 (%2): %3") + .arg(requestId, title, rejection)); + } + + emit sessionEvent( + Session::PermissionRequested{ + .turnId = turnId, + .requestId = requestId, + .toolCallId = singleLineAgentText(toolCall.toolCallId, maxPermissionTitleLength), + .title = title, + .toolKind = clampAgentText(toolCall.kind, maxPermissionKindLength), + .options = offered}); + + if (!rejection.isEmpty()) { + m_ledger.cancelPermission(requestId); + emit sessionEvent( + Session::PermissionResolved{ + .turnId = turnId, .requestId = requestId, .cancelled = true}); + } +} + +bool AcpChatBackend::respondPermission(const QString &requestId, const QString &optionId) +{ + if (!m_ledger.resolvePermission(requestId, optionId)) + return false; + + emit sessionEvent( + Session::PermissionResolved{ + .turnId = m_ledger.activeTurnId(), .requestId = requestId, .optionId = optionId}); + return true; +} + +void AcpChatBackend::emitPermissionsCancelled(const QString &turnId, const QStringList &requestIds) +{ + for (const QString &requestId : requestIds) { + emit sessionEvent( + Session::PermissionResolved{ + .turnId = turnId, .requestId = requestId, .cancelled = true}); + } +} + +void AcpChatBackend::cancelPendingPermissions(const QString &turnId) +{ + emitPermissionsCancelled(turnId, m_ledger.drainPermissions()); +} + +void AcpChatBackend::finishTurn(const QString &turnId) +{ + emitPermissionsCancelled(turnId, m_ledger.endTurn()); +} + +void AcpChatBackend::cancel() +{ + const QString turnId = m_ledger.activeTurnId(); + const bool hadTurn = m_ledger.hasActiveTurn(); + + m_pendingTurn = {}; + + if (hadTurn && !m_sessionId.isEmpty()) + m_handoverSummary.clear(); + + finishTurn(turnId); + + if (hadTurn && m_client && !m_sessionId.isEmpty()) + m_client->cancel(m_sessionId); +} + +void AcpChatBackend::clearToolSession(const QString &filePath) +{ + Q_UNUSED(filePath) + + cancel(); + releaseClient(); + m_resumeSessionId.clear(); + m_handoverSummary.clear(); +} + +void AcpChatBackend::failTurn(const QString &error, bool dropProcess) +{ + if (!m_ledger.hasActiveTurn()) + return; + + const QString turnId = m_ledger.activeTurnId(); + m_pendingTurn = {}; + + finishTurn(turnId); + + QString details = error; + + const QString hint = m_sessionId.isEmpty() ? runnerHint(m_runner) : QString(); + if (!hint.isEmpty()) + details += QLatin1Char('\n') + hint; + + if (!m_stderr.isEmpty()) + details += QLatin1Char('\n') + tr("Agent output:") + QLatin1Char('\n') + + m_stderr.join(QLatin1Char('\n')); + + if (dropProcess) + releaseClient(); + + emit sessionEvent(Session::TurnFailed{.turnId = turnId, .error = details}); +} + +void AcpChatBackend::releaseClient() +{ + LLMQore::Acp::AcpClient *client = m_client; + m_client = nullptr; + ++m_clientGeneration; + m_establishingSession = false; + + if (!m_sessionId.isEmpty()) + m_resumeSessionId = m_sessionId; + + m_sessionId.clear(); + m_agentInfo = {}; + m_authenticated = false; + stopKnowledgeServer(); + + m_earlyCommands.clear(); + m_earlyCommandsSessionId.clear(); + m_earlyTitle.clear(); + m_earlyTitleSessionId.clear(); + if (!m_availableCommands.isEmpty()) { + m_availableCommands.clear(); + emit availableCommandsChanged(); + } + + cancelPendingPermissions(m_ledger.activeTurnId()); + + if (!client) + return; + + client->disconnect(this); + + QMetaObject::invokeMethod( + this, + [client]() { + client->shutdown(); + client->deleteLater(); + }, + Qt::QueuedConnection); +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AcpChatBackend.hpp b/sources/acp/AcpChatBackend.hpp new file mode 100644 index 0000000..3c138f3 --- /dev/null +++ b/sources/acp/AcpChatBackend.hpp @@ -0,0 +1,135 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include +#include + +#include + +#include "acp/AgentDefinition.hpp" +#include "acp/AgentKnowledgeService.hpp" +#include "acp/AgentSpawn.hpp" +#include "acp/ChatPermissionProvider.hpp" +#include "session/ChatBackend.hpp" +#include "session/ContentBlock.hpp" +#include "session/TurnContext.hpp" +#include "session/TurnLedger.hpp" + +namespace QodeAssist::Acp { + +class AcpChatBackend : public Session::ChatBackend +{ + Q_OBJECT + +public: + using ClientFactory = std::function< + AgentProcess(const AgentDefinition &agent, const QString &cwd, QObject *parent)>; + using StoredContentLoader + = std::function; + + explicit AcpChatBackend(QObject *parent = nullptr); + + void setClientFactory(ClientFactory factory); + void setStoredContentLoader(StoredContentLoader loader); + void setKnowledgeService(AgentKnowledgeService *service); + + void bindAgent(const AgentDefinition &agent); + QString boundAgentId() const; + QString boundAgentName() const; + const QList &availableCommands() const + { + return m_availableCommands; + } + QString acpSessionId() const { return m_sessionId; } + QString bindingSessionId() const + { + return m_sessionId.isEmpty() ? m_resumeSessionId : m_sessionId; + } + + void resumeSession(const QString &sessionId); + void startFreshSession(); + void setHandoverSummary(const QString &summary); + bool isResumePending() const { return !m_resumeSessionId.isEmpty(); } + + void sendTurn(const Session::TurnRequest &request) override; + void cancel() override; + bool respondPermission(const QString &requestId, const QString &optionId) override; + Session::TurnContextNeeds contextNeeds() const override { return {false}; } + void setChatFilePath(const QString &filePath) override; + void clearToolSession(const QString &filePath) override; + +signals: + void agentSessionUnavailable(const QString &reason); + void availableCommandsChanged(); + +private: + struct PendingTurn + { + QList userBlocks; + }; + + void startClient(); + void adoptEarlyCommands(); + void startSession(); + void resumeOrStartSession(); + void sendPrompt(); + void authenticateAndRetry(); + + QList buildPrompt() const; + void appendAttachment( + QList &blocks, + const Session::AttachmentBlock &attachment) const; + void appendImage( + QList &blocks, const Session::ImageBlock &image) const; + + void connectClient(); + void requestPermission( + const QString &requestId, + const LLMQore::Acp::ToolCall &toolCall, + const QList &options); + void emitPermissionsCancelled(const QString &turnId, const QStringList &requestIds); + void cancelPendingPermissions(const QString &turnId); + void finishTurn(const QString &turnId); + void failTurn(const QString &error, bool dropProcess); + void releaseClient(); + + QList knowledgeServers(); + void stopKnowledgeServer(); + + ClientFactory m_clientFactory; + StoredContentLoader m_storedContentLoader; + AgentKnowledgeService *m_knowledgeService = nullptr; + bool m_knowledgeServerRunning = false; + ChatPermissionProvider *m_permissions = nullptr; + std::optional m_agent; + + LLMQore::Acp::AcpClient *m_client = nullptr; + LLMQore::Acp::InitializeResult m_agentInfo; + QString m_sessionId; + QString m_workingDirectory; + QString m_runner; + + QString m_chatFilePath; + QString m_resumeSessionId; + QString m_handoverSummary; + QList m_availableCommands; + QList m_earlyCommands; + QString m_earlyCommandsSessionId; + QString m_earlyTitle; + QString m_earlyTitleSessionId; + Session::TurnLedger m_ledger; + int m_clientGeneration = 0; + bool m_establishingSession = false; + PendingTurn m_pendingTurn; + QStringList m_stderr; + int m_turnCounter = 0; + bool m_authenticated = false; +}; + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentBinding.cpp b/sources/acp/AgentBinding.cpp new file mode 100644 index 0000000..d8a767d --- /dev/null +++ b/sources/acp/AgentBinding.cpp @@ -0,0 +1,88 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentBinding.hpp" + +#include + +namespace QodeAssist::Acp { + +namespace { + +bool isUsableId(const QString &id) +{ + if (id.size() > maxAgentBindingIdLength) + return false; + + for (const QChar c : id) { + if (c.category() == QChar::Other_Control || c.category() == QChar::Other_Format) + return false; + } + + return true; +} + +} // namespace + +QString AgentBinding::displayId() const +{ + return agentId.size() <= maxAgentBindingIdLength + ? agentId + : agentId.first(maxAgentBindingIdLength - 1) + QChar(0x2026); +} + +QJsonObject AgentBinding::toJson() const +{ + QJsonObject json; + json["agentId"] = agentId; + if (!sessionId.isEmpty()) + json["sessionId"] = sessionId; + return json; +} + +AgentBinding AgentBinding::fromJson(const QJsonValue &value, QString *error) +{ + const auto fail = [error](const QString &reason) { + if (error) + *error = reason; + return AgentBinding{}; + }; + + if (value.isUndefined() || value.isNull()) + return {}; + + if (!value.isObject()) { + return fail( + QCoreApplication::translate("QodeAssist", "the agent binding is not an object")); + } + + const QJsonObject json = value.toObject(); + + if (!json.value("agentId").isString() && json.contains("agentId")) { + return fail( + QCoreApplication::translate("QodeAssist", "the agent id is not a string")); + } + + if (!json.value("sessionId").isString() && json.contains("sessionId")) { + return fail( + QCoreApplication::translate("QodeAssist", "the agent session id is not a string")); + } + + AgentBinding binding{json["agentId"].toString(), json["sessionId"].toString()}; + + if (binding.agentId.isEmpty() && !binding.sessionId.isEmpty()) { + return fail( + QCoreApplication::translate( + "QodeAssist", "the agent binding names a session but no agent")); + } + + if (!isUsableId(binding.agentId) || !isUsableId(binding.sessionId)) { + return fail( + QCoreApplication::translate("QodeAssist", "the agent binding holds an unusable id")); + } + + return binding; +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentBinding.hpp b/sources/acp/AgentBinding.hpp new file mode 100644 index 0000000..dbba0ef --- /dev/null +++ b/sources/acp/AgentBinding.hpp @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +namespace QodeAssist::Acp { + +inline constexpr qsizetype maxAgentBindingIdLength = 256; + +struct AgentBinding +{ + QString agentId; + QString sessionId; + + bool isEmpty() const { return agentId.isEmpty(); } + + QString displayId() const; + + bool operator==(const AgentBinding &other) const = default; + + QJsonObject toJson() const; + static AgentBinding fromJson(const QJsonValue &value, QString *error = nullptr); +}; + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentCatalog.cpp b/sources/acp/AgentCatalog.cpp new file mode 100644 index 0000000..41a8992 --- /dev/null +++ b/sources/acp/AgentCatalog.cpp @@ -0,0 +1,65 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentCatalog.hpp" + +#include + +#include + +namespace QodeAssist::Acp { + +namespace { + +constexpr std::array mergeOrderLowestPriorityFirst{ + AgentSource::BundledSnapshot, AgentSource::LiveRegistry, AgentSource::UserFile}; + +} // namespace + +void AgentCatalog::setLayer(AgentSource source, const QList &agents) +{ + m_layers[static_cast(source)] = agents; + rebuild(); +} + +QList AgentCatalog::launchableAgents() const +{ + QList result; + for (const AgentDefinition &agent : m_merged) { + if (agent.isLaunchable()) + result.append(agent); + } + return result; +} + +std::optional AgentCatalog::agent(const QString &id) const +{ + for (const AgentDefinition &agent : m_merged) { + if (agent.id == id) + return agent; + } + return std::nullopt; +} + +void AgentCatalog::rebuild() +{ + QHash byId; + for (AgentSource source : mergeOrderLowestPriorityFirst) { + for (const AgentDefinition &agent : std::as_const(m_layers[static_cast(source)])) + byId.insert(agent.id, agent); + } + + m_merged = byId.values(); + std::sort( + m_merged.begin(), + m_merged.end(), + [](const AgentDefinition &left, const AgentDefinition &right) { + const int byName = QString::compare(left.name, right.name, Qt::CaseInsensitive); + if (byName != 0) + return byName < 0; + return left.id < right.id; + }); +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentCatalog.hpp b/sources/acp/AgentCatalog.hpp new file mode 100644 index 0000000..a9db144 --- /dev/null +++ b/sources/acp/AgentCatalog.hpp @@ -0,0 +1,35 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include +#include + +#include "acp/AgentDefinition.hpp" + +namespace QodeAssist::Acp { + +class AgentCatalog +{ +public: + void setLayer(AgentSource source, const QList &agents); + + QList agents() const { return m_merged; } + QList launchableAgents() const; + std::optional agent(const QString &id) const; + + int size() const { return m_merged.size(); } + +private: + void rebuild(); + + std::array, agentSourceCount> m_layers; + QList m_merged; +}; + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentCatalogStore.cpp b/sources/acp/AgentCatalogStore.cpp new file mode 100644 index 0000000..12e9d5b --- /dev/null +++ b/sources/acp/AgentCatalogStore.cpp @@ -0,0 +1,165 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentCatalogStore.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "acp/AgentRegistryParser.hpp" + +namespace QodeAssist::Acp { + +namespace { + +QByteArray readFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + return file.readAll(); +} + +} // namespace + +AgentCatalogStore::AgentCatalogStore(QObject *parent) + : QObject(parent) + , m_network(new QNetworkAccessManager(this)) +{} + +QString AgentCatalogStore::userAgentsDirectory() +{ + const QString path = QStringLiteral("%1/qodeassist/agents") + .arg(Core::ICore::userResourcePath().toFSPathString()); + QDir().mkpath(path); + return path; +} + +QString AgentCatalogStore::registryCachePath() +{ + const QString directory + = QStringLiteral("%1/qodeassist").arg(Core::ICore::userResourcePath().toFSPathString()); + QDir().mkpath(directory); + return directory + QStringLiteral("/acp-registry-cache.json"); +} + +QUrl AgentCatalogStore::registryUrl() +{ + return QUrl( + QStringLiteral("https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json")); +} + +QString AgentCatalogStore::bundledSnapshotPath() +{ + return QStringLiteral(":/resources/agents/acp-registry-snapshot.json"); +} + +bool AgentCatalogStore::hasCachedRegistry() const +{ + return QFileInfo::exists(registryCachePath()); +} + +void AgentCatalogStore::reload() +{ + m_warnings.clear(); + loadBundledSnapshot(); + loadRegistryCache(); + loadUserFiles(); + emit catalogChanged(); +} + +void AgentCatalogStore::loadBundledSnapshot() +{ + const AgentParseResult result = AgentRegistryParser::parse( + readFile(bundledSnapshotPath()), + AgentSource::BundledSnapshot, + QStringLiteral("bundled snapshot")); + + m_warnings.append(result.warnings); + m_catalog.setLayer(AgentSource::BundledSnapshot, result.agents); +} + +void AgentCatalogStore::loadRegistryCache() +{ + const QString path = registryCachePath(); + if (!QFileInfo::exists(path)) { + m_catalog.setLayer(AgentSource::LiveRegistry, {}); + return; + } + + const AgentParseResult result = AgentRegistryParser::parse( + readFile(path), AgentSource::LiveRegistry, QStringLiteral("registry cache")); + + m_warnings.append(result.warnings); + m_catalog.setLayer(AgentSource::LiveRegistry, result.agents); +} + +void AgentCatalogStore::loadUserFiles() +{ + const QDir directory(userAgentsDirectory()); + const QStringList files + = directory.entryList({QStringLiteral("*.json")}, QDir::Files, QDir::Name); + + QList agents; + for (const QString &file : files) { + const QString path = directory.filePath(file); + const AgentParseResult result + = AgentRegistryParser::parse(readFile(path), AgentSource::UserFile, file); + m_warnings.append(result.warnings); + agents.append(result.agents); + } + + m_catalog.setLayer(AgentSource::UserFile, agents); +} + +void AgentCatalogStore::refreshFromRegistry() +{ + if (m_reply) + return; + + m_reply = m_network->get(QNetworkRequest(registryUrl())); + connect(m_reply, &QNetworkReply::finished, this, [this]() { + QNetworkReply *reply = m_reply; + m_reply = nullptr; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit refreshFinished(false, reply->errorString()); + return; + } + + const QByteArray payload = reply->readAll(); + const AgentParseResult result = AgentRegistryParser::parse( + payload, AgentSource::LiveRegistry, QStringLiteral("registry")); + + if (result.agents.isEmpty()) { + emit refreshFinished(false, tr("The registry response contained no agents.")); + return; + } + + QSaveFile cache(registryCachePath()); + if (!cache.open(QIODevice::WriteOnly) || cache.write(payload) != payload.size() + || !cache.commit()) { + emit refreshFinished( + false, tr("Cannot write the registry cache: %1").arg(cache.errorString())); + return; + } + + LOG_MESSAGE(QString("ACP registry refreshed: %1 agents").arg(result.agents.size())); + + reload(); + emit refreshFinished(true, {}); + }); +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentCatalogStore.hpp b/sources/acp/AgentCatalogStore.hpp new file mode 100644 index 0000000..74d3ce8 --- /dev/null +++ b/sources/acp/AgentCatalogStore.hpp @@ -0,0 +1,57 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include + +#include "acp/AgentCatalog.hpp" + +QT_BEGIN_NAMESPACE +class QNetworkAccessManager; +class QNetworkReply; +QT_END_NAMESPACE + +namespace QodeAssist::Acp { + +class AgentCatalogStore : public QObject +{ + Q_OBJECT + +public: + explicit AgentCatalogStore(QObject *parent = nullptr); + + static QString userAgentsDirectory(); + static QString registryCachePath(); + static QUrl registryUrl(); + static QString bundledSnapshotPath(); + + const AgentCatalog &catalog() const { return m_catalog; } + QStringList warnings() const { return m_warnings; } + + bool hasCachedRegistry() const; + bool isRefreshing() const { return m_reply != nullptr; } + + void reload(); + void refreshFromRegistry(); + +signals: + void catalogChanged(); + void refreshFinished(bool ok, const QString &error); + +private: + void loadBundledSnapshot(); + void loadRegistryCache(); + void loadUserFiles(); + + QNetworkAccessManager *m_network = nullptr; + QNetworkReply *m_reply = nullptr; + AgentCatalog m_catalog; + QStringList m_warnings; +}; + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentDefinition.cpp b/sources/acp/AgentDefinition.cpp new file mode 100644 index 0000000..244444c --- /dev/null +++ b/sources/acp/AgentDefinition.cpp @@ -0,0 +1,94 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentDefinition.hpp" + +#include + +namespace QodeAssist::Acp { + +bool AgentDefinition::isLaunchable() const +{ + switch (distribution.kind) { + case AgentDistributionKind::Npx: + case AgentDistributionKind::Uvx: + return !distribution.package.isEmpty(); + case AgentDistributionKind::Command: + return !distribution.command.isEmpty(); + case AgentDistributionKind::Binary: + case AgentDistributionKind::Unknown: + return false; + } + return false; +} + +QString agentSourceName(AgentSource source) +{ + switch (source) { + case AgentSource::UserFile: + return QStringLiteral("user file"); + case AgentSource::LiveRegistry: + return QStringLiteral("registry"); + case AgentSource::BundledSnapshot: + return QStringLiteral("bundled"); + } + return {}; +} + +QString agentDistributionName(AgentDistributionKind kind) +{ + switch (kind) { + case AgentDistributionKind::Npx: + return QStringLiteral("npx"); + case AgentDistributionKind::Uvx: + return QStringLiteral("uvx"); + case AgentDistributionKind::Binary: + return QStringLiteral("binary"); + case AgentDistributionKind::Command: + return QStringLiteral("command"); + case AgentDistributionKind::Unknown: + return QStringLiteral("unknown"); + } + return {}; +} + +QString currentBinaryPlatform() +{ + const QString kernel = QSysInfo::kernelType(); + QString os; + if (kernel == QLatin1String("darwin")) + os = QStringLiteral("darwin"); + else if (kernel == QLatin1String("linux")) + os = QStringLiteral("linux"); + else if (kernel == QLatin1String("winnt")) + os = QStringLiteral("windows"); + else + return {}; + + const QString cpu = QSysInfo::currentCpuArchitecture(); + QString arch; + if (cpu == QLatin1String("arm64") || cpu == QLatin1String("aarch64")) + arch = QStringLiteral("aarch64"); + else if (cpu == QLatin1String("x86_64")) + arch = QStringLiteral("x86_64"); + else + return {}; + + return os + QLatin1Char('-') + arch; +} + +const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent) +{ + const QString platform = currentBinaryPlatform(); + if (platform.isEmpty()) + return nullptr; + + for (const AgentBinaryTarget &target : agent.distribution.binaryTargets) { + if (target.platform == platform) + return ⌖ + } + return nullptr; +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentDefinition.hpp b/sources/acp/AgentDefinition.hpp new file mode 100644 index 0000000..d7c2c6a --- /dev/null +++ b/sources/acp/AgentDefinition.hpp @@ -0,0 +1,69 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +namespace QodeAssist::Acp { + +enum class AgentSource { BundledSnapshot, LiveRegistry, UserFile }; + +inline constexpr int agentSourceCount = 3; + +enum class AgentDistributionKind { Unknown, Npx, Uvx, Binary, Command }; + +struct AgentEnvVariable +{ + QString name; + QString value; +}; + +struct AgentBinaryTarget +{ + QString platform; + QString archive; + QString sha256; + QString cmd; + QStringList args; + QList env; +}; + +struct AgentDistribution +{ + AgentDistributionKind kind = AgentDistributionKind::Unknown; + QString package; + QString command; + QStringList args; + QList env; + QList binaryTargets; +}; + +struct AgentDefinition +{ + QString id; + QString name; + QString version; + QString description; + QString icon; + QString repository; + QString website; + QString license; + QStringList authors; + AgentDistribution distribution; + AgentSource source = AgentSource::BundledSnapshot; + QString origin; + + bool isLaunchable() const; +}; + +QString agentSourceName(AgentSource source); +QString agentDistributionName(AgentDistributionKind kind); + +QString currentBinaryPlatform(); +const AgentBinaryTarget *binaryTargetForCurrentPlatform(const AgentDefinition &agent); + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentKnowledgeService.hpp b/sources/acp/AgentKnowledgeService.hpp new file mode 100644 index 0000000..1d9cc03 --- /dev/null +++ b/sources/acp/AgentKnowledgeService.hpp @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist::Acp { + +class AgentKnowledgeService +{ +public: + virtual ~AgentKnowledgeService() = default; + + virtual QString start() = 0; + virtual void stop() = 0; + virtual QString serverName() const = 0; +}; + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentLaunch.cpp b/sources/acp/AgentLaunch.cpp new file mode 100644 index 0000000..12f60a2 --- /dev/null +++ b/sources/acp/AgentLaunch.cpp @@ -0,0 +1,185 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentLaunch.hpp" + +#include +#include +#include +#include +#include + +namespace QodeAssist::Acp { + +namespace { + +QList toLlmqoreEnv(const QList &env) +{ + QList result; + result.reserve(env.size()); + for (const AgentEnvVariable &variable : env) + result.append({variable.name, variable.value}); + return result; +} + +void applyRunner( + LLMQore::Acp::AcpAgentConfig &config, + const QString &runner, + const QStringList &runnerFlags, + const AgentDistribution &distribution) +{ + config.command = runner; + config.args = runnerFlags; + config.args.append(distribution.package); + config.args.append(distribution.args); +} + +QProcessEnvironment harvestLoginShellEnvironment() +{ + QProcessEnvironment result; + +#ifndef Q_OS_WIN + static const QRegularExpression assignment(QStringLiteral("^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")); + + const QString shell = qEnvironmentVariable("SHELL", QStringLiteral("/bin/sh")); + + QProcess process; + process.setProcessChannelMode(QProcess::SeparateChannels); + process.start( + shell, + {QStringLiteral("-l"), QStringLiteral("-i"), QStringLiteral("-c"), QStringLiteral("env")}); + + if (!process.waitForFinished(5000)) { + process.kill(); + process.waitForFinished(1000); + return result; + } + + const QString output = QString::fromUtf8(process.readAllStandardOutput()); + const QList lines = output.split(QLatin1Char('\n'), Qt::SkipEmptyParts); + for (const QString &line : lines) { + const QRegularExpressionMatch match = assignment.match(line); + if (match.hasMatch()) + result.insert(match.captured(1), match.captured(2)); + } +#endif + + return result; +} + +const QProcessEnvironment &loginShellEnvironment() +{ + static const QProcessEnvironment environment = harvestLoginShellEnvironment(); + return environment; +} + +} // namespace + +std::optional agentLaunchConfig( + const AgentDefinition &agent, const QString &workingDirectory) +{ + if (!agent.isLaunchable()) + return std::nullopt; + + LLMQore::Acp::AcpAgentConfig config; + config.cwd = workingDirectory; + config.env = toLlmqoreEnv(agent.distribution.env); + + switch (agent.distribution.kind) { + case AgentDistributionKind::Npx: + applyRunner( + config, QStringLiteral("npx"), QStringList{QStringLiteral("-y")}, agent.distribution); + break; + case AgentDistributionKind::Uvx: + applyRunner(config, QStringLiteral("uvx"), {}, agent.distribution); + break; + case AgentDistributionKind::Command: + config.command = agent.distribution.command; + config.args = agent.distribution.args; + break; + case AgentDistributionKind::Binary: + case AgentDistributionKind::Unknown: + return std::nullopt; + } + + return config; +} + +QStringList splitSearchPaths(const QString &value) +{ + QStringList directories; + const QList entries = value.split(QDir::listSeparator(), Qt::SkipEmptyParts); + for (const QString &entry : entries) { + const QString trimmed = entry.trimmed(); + if (!trimmed.isEmpty()) + directories.append(trimmed); + } + return directories; +} + +void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories) +{ + if (extraDirectories.isEmpty()) + return; + + const bool isBareName = !config.command.isEmpty() && !config.command.contains(QLatin1Char('/')) + && !config.command.contains(QLatin1Char('\\')); + if (isBareName) { + const QString resolved = QStandardPaths::findExecutable(config.command, extraDirectories); + if (!resolved.isEmpty()) + config.command = resolved; + } + + const QChar separator = QDir::listSeparator(); + const QString inherited = QProcessEnvironment::systemEnvironment().value(QStringLiteral("PATH")); + const QString merged = inherited.isEmpty() + ? extraDirectories.join(separator) + : extraDirectories.join(separator) + separator + inherited; + + config.env.prepend({QStringLiteral("PATH"), merged}); +} + +QStringList splitVariableNames(const QString &value) +{ + static const QRegularExpression separators(QStringLiteral("[,;\\s]+")); + + QStringList names; + const QList entries = value.split(separators, Qt::SkipEmptyParts); + for (const QString &entry : entries) { + const QString trimmed = entry.trimmed(); + if (!trimmed.isEmpty()) + names.append(trimmed); + } + return names; +} + +void applyForwardedEnvironment(LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames) +{ + if (variableNames.isEmpty()) + return; + + const QProcessEnvironment inherited = QProcessEnvironment::systemEnvironment(); + + QList forwarded; + QStringList missing; + for (const QString &name : variableNames) { + if (inherited.contains(name)) + forwarded.append({name, inherited.value(name)}); + else + missing.append(name); + } + + if (!missing.isEmpty()) { + const QProcessEnvironment &shell = loginShellEnvironment(); + for (const QString &name : std::as_const(missing)) { + if (shell.contains(name)) + forwarded.append({name, shell.value(name)}); + } + } + + for (auto it = forwarded.crbegin(); it != forwarded.crend(); ++it) + config.env.prepend(*it); +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentLaunch.hpp b/sources/acp/AgentLaunch.hpp new file mode 100644 index 0000000..f08adef --- /dev/null +++ b/sources/acp/AgentLaunch.hpp @@ -0,0 +1,29 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include + +#include + +#include "acp/AgentDefinition.hpp" + +namespace QodeAssist::Acp { + +std::optional agentLaunchConfig( + const AgentDefinition &agent, const QString &workingDirectory); + +QStringList splitSearchPaths(const QString &value); + +void applyExtraSearchPaths(LLMQore::Acp::AcpAgentConfig &config, const QStringList &extraDirectories); + +QStringList splitVariableNames(const QString &value); + +void applyForwardedEnvironment( + LLMQore::Acp::AcpAgentConfig &config, const QStringList &variableNames); + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentRegistryParser.cpp b/sources/acp/AgentRegistryParser.cpp new file mode 100644 index 0000000..99218a1 --- /dev/null +++ b/sources/acp/AgentRegistryParser.cpp @@ -0,0 +1,223 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentRegistryParser.hpp" + +#include +#include +#include +#include + +namespace QodeAssist::Acp { + +namespace { + +QStringList readStringList(const QJsonValue &value) +{ + QStringList result; + const QJsonArray array = value.toArray(); + for (const QJsonValue &entry : array) { + if (entry.isString()) + result.append(entry.toString()); + } + return result; +} + +QList readEnv(const QJsonValue &value) +{ + QList result; + + if (value.isObject()) { + const QJsonObject object = value.toObject(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) + result.append({it.key(), it.value().toString()}); + return result; + } + + const QJsonArray array = value.toArray(); + for (const QJsonValue &entry : array) { + const QJsonObject object = entry.toObject(); + const QString name = object.value(QLatin1String("name")).toString(); + if (!name.isEmpty()) + result.append({name, object.value(QLatin1String("value")).toString()}); + } + return result; +} + +AgentDistribution readRunnerDistribution(const QJsonObject &object, AgentDistributionKind kind) +{ + AgentDistribution distribution; + distribution.kind = kind; + distribution.package = object.value(QLatin1String("package")).toString(); + distribution.args = readStringList(object.value(QLatin1String("args"))); + distribution.env = readEnv(object.value(QLatin1String("env"))); + return distribution; +} + +AgentDistribution readBinaryDistribution(const QJsonObject &object) +{ + AgentDistribution distribution; + distribution.kind = AgentDistributionKind::Binary; + + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + const QJsonObject entry = it.value().toObject(); + AgentBinaryTarget target; + target.platform = it.key(); + target.archive = entry.value(QLatin1String("archive")).toString(); + target.sha256 = entry.value(QLatin1String("sha256")).toString(); + target.cmd = entry.value(QLatin1String("cmd")).toString(); + target.args = readStringList(entry.value(QLatin1String("args"))); + target.env = readEnv(entry.value(QLatin1String("env"))); + distribution.binaryTargets.append(target); + } + return distribution; +} + +AgentDistribution readCommandDistribution(const QJsonObject &object) +{ + AgentDistribution distribution; + distribution.kind = AgentDistributionKind::Command; + distribution.command = object.value(QLatin1String("cmd")).toString(); + if (distribution.command.isEmpty()) + distribution.command = object.value(QLatin1String("command")).toString(); + distribution.args = readStringList(object.value(QLatin1String("args"))); + distribution.env = readEnv(object.value(QLatin1String("env"))); + return distribution; +} + +QString distributionProblem(const AgentDistribution &distribution) +{ + switch (distribution.kind) { + case AgentDistributionKind::Unknown: + return QStringLiteral("has no supported distribution"); + case AgentDistributionKind::Npx: + case AgentDistributionKind::Uvx: + if (distribution.package.isEmpty()) + return QStringLiteral("has a %1 distribution without a package") + .arg(agentDistributionName(distribution.kind)); + break; + case AgentDistributionKind::Command: + if (distribution.command.isEmpty()) + return QStringLiteral("has a command distribution without a cmd"); + break; + case AgentDistributionKind::Binary: + if (distribution.binaryTargets.isEmpty()) + return QStringLiteral("has a binary distribution without platform targets"); + break; + } + return {}; +} + +AgentDistribution readDistribution(const QJsonObject &object) +{ + if (object.contains(QLatin1String("npx"))) + return readRunnerDistribution( + object.value(QLatin1String("npx")).toObject(), AgentDistributionKind::Npx); + + if (object.contains(QLatin1String("uvx"))) + return readRunnerDistribution( + object.value(QLatin1String("uvx")).toObject(), AgentDistributionKind::Uvx); + + if (object.contains(QLatin1String("command"))) + return readCommandDistribution(object.value(QLatin1String("command")).toObject()); + + if (object.contains(QLatin1String("binary"))) + return readBinaryDistribution(object.value(QLatin1String("binary")).toObject()); + + return {}; +} + +} // namespace + +namespace AgentRegistryParser { + +AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin) +{ + AgentParseResult result; + + QJsonArray entries; + if (root.isArray()) { + entries = root.toArray(); + } else if (root.isObject()) { + const QJsonObject object = root.toObject(); + if (object.contains(QLatin1String("agents"))) { + const QJsonValue agents = object.value(QLatin1String("agents")); + if (!agents.isArray()) { + result.warnings.append(QStringLiteral("%1: 'agents' is not an array").arg(origin)); + return result; + } + entries = agents.toArray(); + } else if (object.contains(QLatin1String("id"))) + entries.append(root); + else + result.warnings.append(QStringLiteral("%1: no agents found").arg(origin)); + } else { + result.warnings.append(QStringLiteral("%1: not a JSON object or array").arg(origin)); + return result; + } + + int index = 0; + for (const QJsonValue &entry : std::as_const(entries)) { + const int position = index++; + if (!entry.isObject()) { + result.warnings.append( + QStringLiteral("%1: entry %2 is not an object").arg(origin).arg(position)); + continue; + } + + const QJsonObject object = entry.toObject(); + AgentDefinition agent; + agent.id = object.value(QLatin1String("id")).toString(); + if (agent.id.isEmpty()) { + result.warnings.append( + QStringLiteral("%1: entry %2 has no id").arg(origin).arg(position)); + continue; + } + + agent.name = object.value(QLatin1String("name")).toString(); + if (agent.name.isEmpty()) + agent.name = agent.id; + + agent.version = object.value(QLatin1String("version")).toString(); + agent.description = object.value(QLatin1String("description")).toString(); + agent.icon = object.value(QLatin1String("icon")).toString(); + agent.repository = object.value(QLatin1String("repository")).toString(); + agent.website = object.value(QLatin1String("website")).toString(); + agent.license = object.value(QLatin1String("license")).toString(); + agent.authors = readStringList(object.value(QLatin1String("authors"))); + agent.distribution = readDistribution( + object.value(QLatin1String("distribution")).toObject()); + agent.source = source; + agent.origin = origin; + + const QString problem = distributionProblem(agent.distribution); + if (!problem.isEmpty()) { + result.warnings.append( + QStringLiteral("%1: agent '%2' %3").arg(origin, agent.id, problem)); + } + + result.agents.append(agent); + } + + return result; +} + +AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin) +{ + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(json, &error); + if (document.isNull()) { + AgentParseResult result; + result.warnings.append(QStringLiteral("%1: %2").arg(origin, error.errorString())); + return result; + } + + if (document.isArray()) + return parse(QJsonValue(document.array()), source, origin); + return parse(QJsonValue(document.object()), source, origin); +} + +} // namespace AgentRegistryParser + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentRegistryParser.hpp b/sources/acp/AgentRegistryParser.hpp new file mode 100644 index 0000000..f0be452 --- /dev/null +++ b/sources/acp/AgentRegistryParser.hpp @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include +#include + +#include "acp/AgentDefinition.hpp" + +namespace QodeAssist::Acp { + +struct AgentParseResult +{ + QList agents; + QStringList warnings; +}; + +namespace AgentRegistryParser { + +AgentParseResult parse(const QJsonValue &root, AgentSource source, const QString &origin); +AgentParseResult parse(const QByteArray &json, AgentSource source, const QString &origin); + +} // namespace AgentRegistryParser + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentSpawn.cpp b/sources/acp/AgentSpawn.cpp new file mode 100644 index 0000000..f176d42 --- /dev/null +++ b/sources/acp/AgentSpawn.cpp @@ -0,0 +1,76 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentSpawn.hpp" + +#include +#include + +#include +#include +#include +#include + +#include + +#include "acp/AgentLaunch.hpp" +#include "settings/AgentsSettings.hpp" + +namespace QodeAssist::Acp { + +namespace { + +LLMQore::Acp::Implementation clientIdentity() +{ + QString version; + for (const ExtensionSystem::PluginSpec *spec : ExtensionSystem::PluginManager::plugins()) { + if (spec->name() == QLatin1String("QodeAssist")) { + version = spec->version(); + break; + } + } + return {QStringLiteral("QodeAssist"), version, QStringLiteral("QodeAssist")}; +} + +} // namespace + +QString agentWorkingDirectory() +{ + if (const ProjectExplorer::Project *project = ProjectExplorer::ProjectManager::startupProject()) + return project->projectDirectory().path(); + return QDir::homePath(); +} + +AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent) +{ + auto config = agentLaunchConfig(agent, cwd); + if (!config) + return {}; + + auto &settings = Settings::agentsSettings(); + applyExtraSearchPaths(*config, splitSearchPaths(settings.agentExtraPaths.volatileValue())); + applyForwardedEnvironment( + *config, splitVariableNames(settings.agentForwardedVariables.volatileValue())); + + auto *transport = config->createTransport(nullptr); + auto *client = new LLMQore::Acp::AcpClient(transport, clientIdentity(), parent); + transport->setParent(client); + + return {client, config->command}; +} + +QString runnerHint(const QString &command) +{ + if (command.endsWith(QLatin1String("npx"))) { + return QCoreApplication::translate( + "QtC::QodeAssist", "Make sure Node.js is installed and 'npx' is on PATH."); + } + if (command.endsWith(QLatin1String("uvx"))) { + return QCoreApplication::translate( + "QtC::QodeAssist", "Make sure uv is installed and 'uvx' is on PATH."); + } + return {}; +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentSpawn.hpp b/sources/acp/AgentSpawn.hpp new file mode 100644 index 0000000..352cb11 --- /dev/null +++ b/sources/acp/AgentSpawn.hpp @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include "acp/AgentDefinition.hpp" + +namespace LLMQore::Acp { +class AcpClient; +} + +namespace QodeAssist::Acp { + +struct AgentProcess +{ + LLMQore::Acp::AcpClient *client = nullptr; + QString command; +}; + +QString agentWorkingDirectory(); + +AgentProcess spawnAgent(const AgentDefinition &agent, const QString &cwd, QObject *parent); + +QString runnerHint(const QString &command); + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentTester.cpp b/sources/acp/AgentTester.cpp new file mode 100644 index 0000000..5c794e8 --- /dev/null +++ b/sources/acp/AgentTester.cpp @@ -0,0 +1,180 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentTester.hpp" + +#include + +#include +#include + +#include "acp/AgentSpawn.hpp" + +namespace QodeAssist::Acp { + +namespace { + +constexpr int maxStderrLines = 20; + +QString capabilityList(const LLMQore::Acp::AgentCapabilities &capabilities) +{ + QStringList prompt; + if (capabilities.promptCapabilities.image) + prompt.append(AgentTester::tr("image")); + if (capabilities.promptCapabilities.audio) + prompt.append(AgentTester::tr("audio")); + if (capabilities.promptCapabilities.embeddedContext) + prompt.append(AgentTester::tr("embedded context")); + return prompt.join(QStringLiteral(", ")); +} + +QString mcpList(const LLMQore::Acp::AgentCapabilities &capabilities) +{ + QStringList transports; + if (capabilities.mcpCapabilities.http) + transports.append(QStringLiteral("http")); + if (capabilities.mcpCapabilities.sse) + transports.append(QStringLiteral("sse")); + return transports.join(QStringLiteral(", ")); +} + +QString describe(const AgentDefinition &agent, const LLMQore::Acp::InitializeResult &result) +{ + const QString name = result.agentInfo && !result.agentInfo->name.isEmpty() + ? result.agentInfo->name + : agent.name; + const QString version = result.agentInfo && !result.agentInfo->version.isEmpty() + ? result.agentInfo->version + : agent.version; + + QStringList lines; + lines.append(version.isEmpty() ? name : QStringLiteral("%1 %2").arg(name, version)); + lines.append(AgentTester::tr("Protocol version: %1").arg(result.protocolVersion)); + lines.append( + AgentTester::tr("Session persistence (loadSession): %1") + .arg( + result.agentCapabilities.loadSession ? AgentTester::tr("supported") + : AgentTester::tr("not supported"))); + + const QString prompt = capabilityList(result.agentCapabilities); + lines.append( + AgentTester::tr("Prompt content: %1") + .arg(prompt.isEmpty() ? AgentTester::tr("text only") : prompt)); + + const QString mcp = mcpList(result.agentCapabilities); + if (!mcp.isEmpty()) + lines.append(AgentTester::tr("MCP transports: %1").arg(mcp)); + + if (result.authMethods.isEmpty()) { + lines.append(AgentTester::tr("Authentication: not required")); + } else { + QStringList methods; + for (const LLMQore::Acp::AuthMethod &method : result.authMethods) + methods.append(method.name.isEmpty() ? method.id : method.name); + lines.append(AgentTester::tr("Authentication: %1").arg(methods.join(QStringLiteral(", ")))); + } + + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +AgentTester::AgentTester(QObject *parent) + : QObject(parent) +{} + +void AgentTester::start(const AgentDefinition &agent, const QString &workingDirectory) +{ + if (m_running) + return; + + const AgentProcess process = spawnAgent(agent, workingDirectory, this); + if (!process.client) { + emit finished(false, tr("This agent has no launchable distribution.")); + return; + } + + m_stderr.clear(); + m_runner = process.command; + m_running = true; + + m_client = process.client; + m_transport = qobject_cast(m_client->transport()); + + connect(m_client, &LLMQore::Acp::AcpClient::agentStderr, this, [this](const QString &line) { + if (m_stderr.size() < maxStderrLines) + m_stderr.append(line); + }); + connect(m_client, &LLMQore::Acp::AcpClient::errorOccurred, this, [this](const QString &error) { + report(false, error); + }); + + m_client->connectAndInitialize(std::chrono::seconds(60)) + .then( + this, + [this, agent](const LLMQore::Acp::InitializeResult &result) { + report(true, describe(agent, result)); + }) + .onFailed(this, [this](const std::exception &e) { + report(false, QString::fromUtf8(e.what())); + }); +} + +void AgentTester::cancel() +{ + if (m_running) + report(false, tr("Test cancelled.")); +} + +void AgentTester::report(bool ok, const QString &text) +{ + if (!m_running) + return; + + m_running = false; + const QString details = ok ? text : text + diagnostics(); + releaseClient(); + emit finished(ok, details); +} + +QString AgentTester::diagnostics() const +{ + QString result; + + const QString hint = runnerHint(m_runner); + if (!hint.isEmpty()) + result += QLatin1Char('\n') + hint; + + if (!m_stderr.isEmpty()) { + result += QLatin1Char('\n') + tr("Agent output:") + QLatin1Char('\n') + + m_stderr.join(QLatin1Char('\n')); + } + + return result; +} + +void AgentTester::releaseClient() +{ + LLMQore::Acp::AcpClient *client = m_client; + LLMQore::Rpc::StdioClientTransport *transport = m_transport; + m_client = nullptr; + m_transport = nullptr; + + if (client) + client->disconnect(this); + + QMetaObject::invokeMethod( + this, + [client, transport]() { + if (client) { + client->shutdown(); + client->deleteLater(); + } + if (transport) + transport->deleteLater(); + }, + Qt::QueuedConnection); +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/AgentTester.hpp b/sources/acp/AgentTester.hpp new file mode 100644 index 0000000..cae43e8 --- /dev/null +++ b/sources/acp/AgentTester.hpp @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +#include "acp/AgentDefinition.hpp" + +namespace LLMQore::Acp { +class AcpClient; +} + +namespace LLMQore::Rpc { +class StdioClientTransport; +} + +namespace QodeAssist::Acp { + +class AgentTester : public QObject +{ + Q_OBJECT + +public: + explicit AgentTester(QObject *parent = nullptr); + + void start(const AgentDefinition &agent, const QString &workingDirectory); + void cancel(); + + bool isRunning() const { return m_running; } + +signals: + void finished(bool ok, const QString &report); + +private: + void report(bool ok, const QString &text); + void releaseClient(); + QString diagnostics() const; + + LLMQore::Acp::AcpClient *m_client = nullptr; + LLMQore::Rpc::StdioClientTransport *m_transport = nullptr; + QStringList m_stderr; + QString m_runner; + bool m_running = false; +}; + +} // namespace QodeAssist::Acp diff --git a/sources/acp/CMakeLists.txt b/sources/acp/CMakeLists.txt new file mode 100644 index 0000000..760e83d --- /dev/null +++ b/sources/acp/CMakeLists.txt @@ -0,0 +1,29 @@ +add_library(QodeAssistAcp STATIC + AgentBinding.hpp AgentBinding.cpp + AgentDefinition.hpp AgentDefinition.cpp + AgentRegistryParser.hpp AgentRegistryParser.cpp + AgentCatalog.hpp AgentCatalog.cpp + AgentLaunch.hpp AgentLaunch.cpp + AgentCatalogStore.hpp AgentCatalogStore.cpp + AgentSpawn.hpp AgentSpawn.cpp + AgentTester.hpp AgentTester.cpp + ChatPermissionProvider.hpp ChatPermissionProvider.cpp + AcpChatBackend.hpp AcpChatBackend.cpp +) + +target_link_libraries(QodeAssistAcp + PUBLIC + Qt::Core + Qt::Network + QtCreator::Core + QtCreator::Utils + QtCreator::ProjectExplorer + QtCreator::ExtensionSystem + LLMQore + QodeAssistSession + PRIVATE + QodeAssistLogger + QodeAssistSettings +) + +target_include_directories(QodeAssistAcp PUBLIC ${CMAKE_SOURCE_DIR}/sources) diff --git a/sources/acp/ChatPermissionProvider.cpp b/sources/acp/ChatPermissionProvider.cpp new file mode 100644 index 0000000..02c9d1e --- /dev/null +++ b/sources/acp/ChatPermissionProvider.cpp @@ -0,0 +1,56 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "ChatPermissionProvider.hpp" + +#include +#include + +#include + +namespace QodeAssist::Acp { + +ChatPermissionProvider::ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent) + : LLMQore::Acp::AcpPermissionProvider(parent) + , m_ledger(ledger) +{} + +void ChatPermissionProvider::setRequestHandler(RequestHandler handler) +{ + m_requestHandler = std::move(handler); +} + +QFuture ChatPermissionProvider::requestPermission( + const QString &sessionId, + const LLMQore::Acp::ToolCall &toolCall, + const QList &options) +{ + Q_UNUSED(sessionId) + + auto promise = std::make_shared>(); + promise->start(); + + const auto finish = [promise](const LLMQore::Acp::RequestPermissionResult &result) { + promise->addResult(result); + promise->finish(); + }; + + if (!m_requestHandler) { + finish(LLMQore::Acp::RequestPermissionResult::cancelled()); + return promise->future(); + } + + const QString requestId = m_ledger->registerPermission( + [finish](const QString &optionId) { + finish(LLMQore::Acp::RequestPermissionResult::selected(optionId)); + }, + [finish] { finish(LLMQore::Acp::RequestPermissionResult::cancelled()); }); + + QFuture future = promise->future(); + m_requestHandler(requestId, toolCall, options); + + return future; +} + +} // namespace QodeAssist::Acp diff --git a/sources/acp/ChatPermissionProvider.hpp b/sources/acp/ChatPermissionProvider.hpp new file mode 100644 index 0000000..479bc46 --- /dev/null +++ b/sources/acp/ChatPermissionProvider.hpp @@ -0,0 +1,41 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include + +#include + +#include "session/TurnLedger.hpp" + +namespace QodeAssist::Acp { + +class ChatPermissionProvider : public LLMQore::Acp::AcpPermissionProvider +{ + Q_OBJECT + +public: + using RequestHandler = std::function &options)>; + + explicit ChatPermissionProvider(Session::TurnLedger *ledger, QObject *parent = nullptr); + + void setRequestHandler(RequestHandler handler); + + QFuture requestPermission( + const QString &sessionId, + const LLMQore::Acp::ToolCall &toolCall, + const QList &options) override; + +private: + RequestHandler m_requestHandler; + Session::TurnLedger *m_ledger = nullptr; +}; + +} // namespace QodeAssist::Acp diff --git a/sources/completion/LLMClientInterface.cpp b/sources/completion/LLMClientInterface.cpp index 8207e76..fde5ca7 100644 --- a/sources/completion/LLMClientInterface.cpp +++ b/sources/completion/LLMClientInterface.cpp @@ -15,7 +15,6 @@ #include "logger/Logger.hpp" #include "settings/CodeCompletionSettings.hpp" #include "settings/GeneralSettings.hpp" -#include "context/RulesLoader.hpp" namespace QodeAssist { @@ -283,17 +282,6 @@ void LLMClientInterface::handleCompletion(const QJsonObject &request) ? m_completeSettings.systemPromptForNonFimModels() : m_completeSettings.systemPrompt()); - auto project = Context::RulesLoader::getActiveProject(); - if (project) { - QString projectRules - = Context::RulesLoader::loadRulesForProject(project, Context::RulesContext::Completions); - - if (!projectRules.isEmpty()) { - systemPrompt += "\n\n# Project Rules\n\n" + projectRules; - LOG_MESSAGE("Loaded project rules for completion"); - } - } - if (updatedContext.fileContext.has_value()) systemPrompt.append(updatedContext.fileContext.value()); diff --git a/sources/completion/QodeAssistClient.cpp b/sources/completion/QodeAssistClient.cpp index ca67562..651a680 100644 --- a/sources/completion/QodeAssistClient.cpp +++ b/sources/completion/QodeAssistClient.cpp @@ -45,7 +45,6 @@ #include "settings/QuickRefactorSettings.hpp" #include "widgets/RefactorWidgetHandler.hpp" #include "refactor/RefactorContextHelper.hpp" -#include #include using namespace LanguageServerProtocol; @@ -201,10 +200,6 @@ void QodeAssistClient::openDocument(TextEditor::TextDocument *document) if (!textEditor || textEditor->document() != document) return; - if (Settings::codeCompletionSettings().useProjectChangesCache()) - Context::ChangesManager::instance() - .addChange(document, position, charsRemoved, charsAdded); - TextEditorWidget *widget = textEditor->editorWidget(); if (widget->isReadOnly() || widget->multiTextCursor().hasMultipleCursors()) return; diff --git a/sources/context/CMakeLists.txt b/sources/context/CMakeLists.txt index 26dff41..769bd57 100644 --- a/sources/context/CMakeLists.txt +++ b/sources/context/CMakeLists.txt @@ -1,6 +1,6 @@ add_library(Context STATIC DocumentContextReader.hpp DocumentContextReader.cpp - ChangesManager.h ChangesManager.cpp + FileEditManager.hpp FileEditManager.cpp ContextManager.hpp ContextManager.cpp ContentFile.hpp DocumentReaderQtCreator.hpp @@ -10,7 +10,6 @@ add_library(Context STATIC IContextManager.hpp IgnoreManager.hpp IgnoreManager.cpp ProjectUtils.hpp ProjectUtils.cpp - RulesLoader.hpp RulesLoader.cpp ) target_link_libraries(Context diff --git a/sources/context/ContentFile.hpp b/sources/context/ContentFile.hpp index 6301e06..7444220 100644 --- a/sources/context/ContentFile.hpp +++ b/sources/context/ContentFile.hpp @@ -12,6 +12,7 @@ struct ContentFile { QString filename; QString content; + QString path; }; } // namespace QodeAssist::Context diff --git a/sources/context/ContextManager.cpp b/sources/context/ContextManager.cpp index 8ac9714..7de864c 100644 --- a/sources/context/ContextManager.cpp +++ b/sources/context/ContextManager.cpp @@ -41,9 +41,9 @@ QString ContextManager::readFile(const QString &filePath) const return content; } -QList ContextManager::getContentFiles(const QStringList &filePaths) const +QStringList ContextManager::allowedPaths(const QStringList &filePaths) const { - QList files; + QStringList allowed; for (const QString &path : filePaths) { auto project = ProjectExplorer::ProjectManager::projectForFile( Utils::FilePath::fromString(path)); @@ -52,9 +52,16 @@ QList ContextManager::getContentFiles(const QStringList &filePaths) continue; } - ContentFile contentFile = createContentFile(path); - files.append(contentFile); + allowed.append(path); } + return allowed; +} + +QList ContextManager::getContentFiles(const QStringList &filePaths) const +{ + QList files; + for (const QString &path : allowedPaths(filePaths)) + files.append(createContentFile(path)); return files; } @@ -85,6 +92,7 @@ ContentFile ContextManager::createContentFile(const QString &filePath) const QFileInfo fileInfo(filePath); contentFile.filename = fileInfo.fileName(); contentFile.content = readFile(filePath); + contentFile.path = fileInfo.absoluteFilePath(); return contentFile; } diff --git a/sources/context/ContextManager.hpp b/sources/context/ContextManager.hpp index 1213afe..a1fb41f 100644 --- a/sources/context/ContextManager.hpp +++ b/sources/context/ContextManager.hpp @@ -27,6 +27,7 @@ public: ~ContextManager() override = default; QString readFile(const QString &filePath) const override; + QStringList allowedPaths(const QStringList &filePaths) const override; QList getContentFiles(const QStringList &filePaths) const override; QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const override; ContentFile createContentFile(const QString &filePath) const override; diff --git a/sources/context/DocumentContextReader.cpp b/sources/context/DocumentContextReader.cpp index a1629cf..ebd12d7 100644 --- a/sources/context/DocumentContextReader.cpp +++ b/sources/context/DocumentContextReader.cpp @@ -10,8 +10,6 @@ #include "CodeCompletionSettings.hpp" -#include "ChangesManager.h" - const QRegularExpression &getYearRegex() { static const QRegularExpression yearRegex("\\b(19|20)\\d{2}\\b"); @@ -274,10 +272,6 @@ LLMCore::ContextData DocumentContextReader::prepareContext( QString fileContext; fileContext.append("\n ").append(getLanguageAndFileInfo()); - if (settings.useProjectChangesCache()) - fileContext.append("Recent Project Changes Context:\n ") - .append(ChangesManager::instance().getRecentChangesContext(m_textDocument)); - return {.prefix = contextBefore, .suffix = contextAfter, .fileContext = fileContext}; } diff --git a/sources/context/ChangesManager.cpp b/sources/context/FileEditManager.cpp similarity index 95% rename from sources/context/ChangesManager.cpp rename to sources/context/FileEditManager.cpp index a74396e..09bdb95 100644 --- a/sources/context/ChangesManager.cpp +++ b/sources/context/FileEditManager.cpp @@ -2,11 +2,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution terms under GPLv3 §7(b) apply — see LICENSE -#include "ChangesManager.h" -#include "CodeCompletionSettings.hpp" +#include "FileEditManager.hpp" #include #include +#include #include #include #include @@ -15,61 +15,20 @@ namespace QodeAssist::Context { -ChangesManager &ChangesManager::instance() +FileEditManager &FileEditManager::instance() { - static ChangesManager instance; + static FileEditManager instance; return instance; } -ChangesManager::ChangesManager() +FileEditManager::FileEditManager() : QObject(nullptr) , m_undoStack(new QUndoStack(this)) {} -ChangesManager::~ChangesManager() {} +FileEditManager::~FileEditManager() {} -void ChangesManager::addChange( - TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded) -{ - auto &documentQueue = m_documentChanges[document]; - - QTextBlock block = document->document()->findBlock(position); - int lineNumber = block.blockNumber(); - QString lineContent = block.text(); - QString fileName = document->filePath().fileName(); - - ChangeInfo change{fileName, lineNumber, lineContent}; - - auto it - = std::find_if(documentQueue.begin(), documentQueue.end(), [lineNumber](const ChangeInfo &c) { - return c.lineNumber == lineNumber; - }); - - if (it != documentQueue.end()) { - it->lineContent = lineContent; - } else { - documentQueue.enqueue(change); - - if (documentQueue.size() > Settings::codeCompletionSettings().maxChangesCacheSize()) { - documentQueue.dequeue(); - } - } -} - -QString ChangesManager::getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const -{ - QString context; - for (auto it = m_documentChanges.constBegin(); it != m_documentChanges.constEnd(); ++it) { - if (it.key() != currentDocument) { - for (const auto &change : it.value()) { - context += change.lineContent + "\n"; - } - } - } - return context; -} - -void ChangesManager::addFileEdit( +void FileEditManager::addFileEdit( const QString &editId, const QString &filePath, const QString &oldContent, @@ -139,7 +98,44 @@ void ChangesManager::addFileEdit( } } -bool ChangesManager::applyFileEdit(const QString &editId) +void FileEditManager::registerAppliedFileEdit( + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent, + const QString &requestId) +{ + QMutexLocker locker(&m_mutex); + + if (m_fileEdits.contains(editId)) { + LOG_MESSAGE(QString("File edit already exists, skipping: %1").arg(editId)); + return; + } + + FileEdit edit; + edit.editId = editId; + edit.filePath = filePath; + edit.oldContent = oldContent; + edit.newContent = newContent; + edit.timestamp = QDateTime::currentDateTime(); + edit.wasAutoApplied = true; + edit.isFromHistory = false; + edit.status = Applied; + edit.statusMessage = "Applied by agent"; + + m_fileEdits.insert(editId, edit); + + if (!requestId.isEmpty()) + m_requestEdits[requestId].editIds.append(editId); + + locker.unlock(); + emit fileEditAdded(editId); + + LOG_MESSAGE(QString("Agent file edit registered as applied: %1 for file %2") + .arg(editId, filePath)); +} + +bool FileEditManager::applyFileEdit(const QString &editId) { QMutexLocker locker(&m_mutex); @@ -193,7 +189,7 @@ bool ChangesManager::applyFileEdit(const QString &editId) return false; } -bool ChangesManager::rejectFileEdit(const QString &editId) +bool FileEditManager::rejectFileEdit(const QString &editId) { QMutexLocker locker(&m_mutex); @@ -220,7 +216,7 @@ bool ChangesManager::rejectFileEdit(const QString &editId) return true; } -bool ChangesManager::undoFileEdit(const QString &editId) +bool FileEditManager::undoFileEdit(const QString &editId) { QMutexLocker locker(&m_mutex); @@ -276,13 +272,13 @@ bool ChangesManager::undoFileEdit(const QString &editId) return false; } -ChangesManager::FileEdit ChangesManager::getFileEdit(const QString &editId) const +FileEditManager::FileEdit FileEditManager::getFileEdit(const QString &editId) const { QMutexLocker locker(&m_mutex); return m_fileEdits.value(editId); } -QList ChangesManager::getPendingEdits() const +QList FileEditManager::getPendingEdits() const { QMutexLocker locker(&m_mutex); @@ -295,7 +291,7 @@ QList ChangesManager::getPendingEdits() const return pendingEdits; } -bool ChangesManager::performFileEdit( +bool FileEditManager::performFileEdit( const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg) { auto setError = [errorMsg](const QString &msg) { @@ -451,7 +447,7 @@ bool ChangesManager::performFileEdit( return true; } -int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) const +int FileEditManager::levenshteinDistance(const QString &s1, const QString &s2) const { const int len1 = s1.length(); const int len2 = s2.length(); @@ -484,7 +480,7 @@ int ChangesManager::levenshteinDistance(const QString &s1, const QString &s2) co return d[len1][len2]; } -QString ChangesManager::findBestMatchLineBased( +QString FileEditManager::findBestMatchLineBased( const QString &fileContent, const QString &searchContent, double threshold, @@ -550,7 +546,7 @@ QString ChangesManager::findBestMatchLineBased( return bestMatch; } -QString ChangesManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const +QString FileEditManager::findBestMatch(const QString &fileContent, const QString &searchContent, double threshold, double *outSimilarity) const { if (searchContent.isEmpty() || fileContent.isEmpty()) { if (outSimilarity) *outSimilarity = 0.0; @@ -631,7 +627,7 @@ QString ChangesManager::findBestMatch(const QString &fileContent, const QString return bestMatch; } -QString ChangesManager::findBestMatchWithNormalization( +QString FileEditManager::findBestMatchWithNormalization( const QString &fileContent, const QString &searchContent, double *outSimilarity, @@ -677,7 +673,7 @@ QString ChangesManager::findBestMatchWithNormalization( return QString(); } -bool ChangesManager::performFragmentReplacement( +bool FileEditManager::performFragmentReplacement( const QString &filePath, const QString &searchContent, const QString &replaceContent, @@ -821,7 +817,7 @@ bool ChangesManager::performFragmentReplacement( return true; } -bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg) +bool FileEditManager::applyPendingEditsForRequest(const QString &requestId, QString *errorMsg) { QMutexLocker locker(&m_mutex); @@ -857,7 +853,7 @@ bool ChangesManager::applyPendingEditsForRequest(const QString &requestId, QStri return true; } -QList ChangesManager::getEditsForRequest(const QString &requestId) const +QList FileEditManager::getEditsForRequest(const QString &requestId) const { QMutexLocker locker(&m_mutex); @@ -877,7 +873,7 @@ QList ChangesManager::getEditsForRequest(const QString return edits; } -bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg) +bool FileEditManager::undoAllEditsForRequest(const QString &requestId, QString *errorMsg) { QMutexLocker locker(&m_mutex); @@ -962,7 +958,7 @@ bool ChangesManager::undoAllEditsForRequest(const QString &requestId, QString *e return true; } -bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg) +bool FileEditManager::reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg) { QMutexLocker locker(&m_mutex); @@ -1046,7 +1042,7 @@ bool ChangesManager::reapplyAllEditsForRequest(const QString &requestId, QString return true; } -void ChangesManager::archiveAllNonArchivedEdits() +void FileEditManager::archiveAllNonArchivedEdits() { QMutexLocker locker(&m_mutex); @@ -1080,7 +1076,7 @@ void ChangesManager::archiveAllNonArchivedEdits() } } -QString ChangesManager::readFileContent(const QString &filePath) const +QString FileEditManager::readFileContent(const QString &filePath) const { LOG_MESSAGE(QString("Reading current file content: %1").arg(filePath)); @@ -1112,7 +1108,7 @@ QString ChangesManager::readFileContent(const QString &filePath) const return content; } -bool ChangesManager::performFileEditWithDiff( +bool FileEditManager::performFileEditWithDiff( const QString &filePath, const DiffInfo &diffInfo, bool reverse, @@ -1244,7 +1240,7 @@ bool ChangesManager::performFileEditWithDiff( return true; } -ChangesManager::DiffInfo ChangesManager::createDiffInfo( +FileEditManager::DiffInfo FileEditManager::createDiffInfo( const QString &originalContent, const QString &modifiedContent, const QString &filePath) @@ -1390,7 +1386,7 @@ ChangesManager::DiffInfo ChangesManager::createDiffInfo( return diffInfo; } -bool ChangesManager::findHunkLocation( +bool FileEditManager::findHunkLocation( const QStringList &fileLines, const DiffHunk &hunk, int &actualStartLine, @@ -1537,7 +1533,7 @@ bool ChangesManager::findHunkLocation( return false; } -bool ChangesManager::applyDiffToContent( +bool FileEditManager::applyDiffToContent( QString &content, const DiffInfo &diffInfo, bool reverse, diff --git a/sources/context/ChangesManager.h b/sources/context/FileEditManager.hpp similarity index 87% rename from sources/context/ChangesManager.h rename to sources/context/FileEditManager.hpp index 91bdcd5..94297d1 100644 --- a/sources/context/ChangesManager.h +++ b/sources/context/FileEditManager.hpp @@ -4,28 +4,22 @@ #pragma once -#include #include #include +#include #include -#include -#include +#include +#include +#include #include namespace QodeAssist::Context { -class ChangesManager : public QObject +class FileEditManager : public QObject { Q_OBJECT public: - struct ChangeInfo - { - QString fileName; - int lineNumber; - QString lineContent; - }; - enum FileEditStatus { Pending, Applied, Rejected, Archived }; struct DiffHunk @@ -63,11 +57,7 @@ public: QString statusMessage; }; - static ChangesManager &instance(); - - void addChange( - TextEditor::TextDocument *document, int position, int charsRemoved, int charsAdded); - QString getRecentChangesContext(const TextEditor::TextDocument *currentDocument) const; + static FileEditManager &instance(); void addFileEdit( const QString &editId, @@ -77,20 +67,26 @@ public: bool autoApply = true, bool isFromHistory = false, const QString &requestId = QString()); + void registerAppliedFileEdit( + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent, + const QString &requestId = QString()); bool applyFileEdit(const QString &editId); bool rejectFileEdit(const QString &editId); bool undoFileEdit(const QString &editId); FileEdit getFileEdit(const QString &editId) const; QList getPendingEdits() const; - + bool applyPendingEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); - + QList getEditsForRequest(const QString &requestId) const; - + bool undoAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); - + bool reapplyAllEditsForRequest(const QString &requestId, QString *errorMsg = nullptr); - + void archiveAllNonArchivedEdits(); signals: @@ -101,19 +97,19 @@ signals: void fileEditArchived(const QString &editId); private: - ChangesManager(); - ~ChangesManager(); - ChangesManager(const ChangesManager &) = delete; - ChangesManager &operator=(const ChangesManager &) = delete; + FileEditManager(); + ~FileEditManager(); + FileEditManager(const FileEditManager &) = delete; + FileEditManager &operator=(const FileEditManager &) = delete; bool performFileEdit(const QString &filePath, const QString &oldContent, const QString &newContent, QString *errorMsg = nullptr); bool performFileEditWithDiff(const QString &filePath, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr); QString readFileContent(const QString &filePath) const; - + DiffInfo createDiffInfo(const QString &originalContent, const QString &modifiedContent, const QString &filePath); bool applyDiffToContent(QString &content, const DiffInfo &diffInfo, bool reverse, QString *errorMsg = nullptr); bool findHunkLocation(const QStringList &fileLines, const DiffHunk &hunk, int &actualStartLine, QString *debugInfo = nullptr) const; - + // Helper method for fragment-based apply/undo operations bool performFragmentReplacement( const QString &filePath, @@ -122,7 +118,7 @@ private: bool isAppendOperation, QString *errorMsg = nullptr, bool isUndo = false); - + int levenshteinDistance(const QString &s1, const QString &s2) const; QString findBestMatch(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const; QString findBestMatchLineBased(const QString &fileContent, const QString &searchContent, double threshold = 0.82, double *outSimilarity = nullptr) const; @@ -134,7 +130,6 @@ private: bool autoApplyPending = false; }; - QHash> m_documentChanges; QHash m_fileEdits; QHash m_requestEdits; // requestId → ordered edits QUndoStack *m_undoStack; diff --git a/sources/context/IContextManager.hpp b/sources/context/IContextManager.hpp index 2f0a592..5f11548 100644 --- a/sources/context/IContextManager.hpp +++ b/sources/context/IContextManager.hpp @@ -23,6 +23,7 @@ public: virtual ~IContextManager() = default; virtual QString readFile(const QString &filePath) const = 0; + virtual QStringList allowedPaths(const QStringList &filePaths) const = 0; virtual QList getContentFiles(const QStringList &filePaths) const = 0; virtual QStringList getProjectSourceFiles(ProjectExplorer::Project *project) const = 0; virtual ContentFile createContentFile(const QString &filePath) const = 0; diff --git a/sources/context/RulesLoader.cpp b/sources/context/RulesLoader.cpp deleted file mode 100644 index c2a40be..0000000 --- a/sources/context/RulesLoader.cpp +++ /dev/null @@ -1,166 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "context/RulesLoader.hpp" - -#include -#include - -#include -#include -#include - -namespace QodeAssist::Context { - -QString RulesLoader::loadRules(const QString &projectPath, RulesContext context) -{ - if (projectPath.isEmpty()) { - return QString(); - } - - QString combined; - QString basePath = projectPath + "/.qodeassist/rules"; - - switch (context) { - case RulesContext::Completions: - combined += loadAllMarkdownFiles(basePath + "/completions"); - break; - case RulesContext::Chat: - combined += loadAllMarkdownFiles(basePath + "/common"); - combined += loadAllMarkdownFiles(basePath + "/chat"); - break; - case RulesContext::QuickRefactor: - combined += loadAllMarkdownFiles(basePath + "/common"); - combined += loadAllMarkdownFiles(basePath + "/quickrefactor"); - break; - } - - return combined; -} - -QString RulesLoader::loadRulesForProject(ProjectExplorer::Project *project, RulesContext context) -{ - if (!project) { - return QString(); - } - - QString projectPath = getProjectPath(project); - return loadRules(projectPath, context); -} - -ProjectExplorer::Project *RulesLoader::getActiveProject() -{ - auto currentEditor = Core::EditorManager::currentEditor(); - if (currentEditor && currentEditor->document()) { - Utils::FilePath filePath = currentEditor->document()->filePath(); - auto project = ProjectExplorer::ProjectManager::projectForFile(filePath); - if (project) { - return project; - } - } - - return ProjectExplorer::ProjectManager::startupProject(); -} - -QString RulesLoader::loadAllMarkdownFiles(const QString &dirPath) -{ - QString combined; - QDir dir(dirPath); - - if (!dir.exists()) { - return QString(); - } - - QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name); - - for (const QString &fileName : mdFiles) { - QFile file(dir.filePath(fileName)); - if (file.open(QIODevice::ReadOnly | QIODevice::Text)) { - combined += file.readAll(); - combined += "\n\n"; - } - } - - return combined; -} - -QString RulesLoader::getProjectPath(ProjectExplorer::Project *project) -{ - if (!project) { - return QString(); - } - - return project->projectDirectory().toUrlishString(); -} - -QVector RulesLoader::getRuleFiles(const QString &projectPath, RulesContext context) -{ - if (projectPath.isEmpty()) { - return QVector(); - } - - QVector result; - QString basePath = projectPath + "/.qodeassist/rules"; - - // Always include common rules - result.append(collectMarkdownFiles(basePath + "/common", "common")); - - // Add context-specific rules - switch (context) { - case RulesContext::Completions: - result.append(collectMarkdownFiles(basePath + "/completions", "completions")); - break; - case RulesContext::Chat: - result.append(collectMarkdownFiles(basePath + "/chat", "chat")); - break; - case RulesContext::QuickRefactor: - result.append(collectMarkdownFiles(basePath + "/quickrefactor", "quickrefactor")); - break; - } - - return result; -} - -QVector RulesLoader::getRuleFilesForProject( - ProjectExplorer::Project *project, RulesContext context) -{ - if (!project) { - return QVector(); - } - - QString projectPath = getProjectPath(project); - return getRuleFiles(projectPath, context); -} - -QString RulesLoader::loadRuleFileContent(const QString &filePath) -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QString(); - } - - return file.readAll(); -} - -QVector RulesLoader::collectMarkdownFiles( - const QString &dirPath, const QString &category) -{ - QVector result; - QDir dir(dirPath); - - if (!dir.exists()) { - return result; - } - - QStringList mdFiles = dir.entryList({"*.md"}, QDir::Files, QDir::Name); - - for (const QString &fileName : mdFiles) { - QString fullPath = dir.filePath(fileName); - result.append({fullPath, fileName, category}); - } - - return result; -} - -} // namespace QodeAssist::Context diff --git a/sources/context/RulesLoader.hpp b/sources/context/RulesLoader.hpp deleted file mode 100644 index 55b5f30..0000000 --- a/sources/context/RulesLoader.hpp +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (C) 2025-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -namespace ProjectExplorer { -class Project; -} - -namespace QodeAssist::Context { - -enum class RulesContext { Completions, Chat, QuickRefactor }; - -struct RuleFileInfo -{ - QString filePath; - QString fileName; - QString category; // "common", "chat", "completions", "quickrefactor" -}; - -class RulesLoader -{ -public: - static QString loadRules(const QString &projectPath, RulesContext context); - static QString loadRulesForProject(ProjectExplorer::Project *project, RulesContext context); - static ProjectExplorer::Project *getActiveProject(); - - // New methods for getting rule files info - static QVector getRuleFiles(const QString &projectPath, RulesContext context); - static QVector getRuleFilesForProject(ProjectExplorer::Project *project, RulesContext context); - static QString loadRuleFileContent(const QString &filePath); - -private: - static QString loadAllMarkdownFiles(const QString &dirPath); - static QVector collectMarkdownFiles(const QString &dirPath, const QString &category); - static QString getProjectPath(ProjectExplorer::Project *project); -}; - -} // namespace QodeAssist::Context diff --git a/sources/external/llmqore b/sources/external/llmqore index 3f9c8f5..1e7088a 160000 --- a/sources/external/llmqore +++ b/sources/external/llmqore @@ -1 +1 @@ -Subproject commit 3f9c8f52534b2fd2699b56d2c22dde03bf0b6924 +Subproject commit 1e7088ad32c49a77895cda354b42f65c61d809dd diff --git a/sources/mcp/AgentKnowledgeServer.cpp b/sources/mcp/AgentKnowledgeServer.cpp new file mode 100644 index 0000000..0a8492f --- /dev/null +++ b/sources/mcp/AgentKnowledgeServer.cpp @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentKnowledgeServer.hpp" + +#include +#include + +#include +#include +#include +#include + +#include + +#include "tools/EditorStateTools.hpp" +#include "tools/GetIssuesListTool.hpp" + +namespace QodeAssist::Mcp { + +namespace { + +QString knowledgeServerName() +{ + return QStringLiteral("qodeassist"); +} + +} // namespace + +AgentKnowledgeServer::AgentKnowledgeServer(QObject *parent) + : QObject(parent) +{} + +AgentKnowledgeServer::~AgentKnowledgeServer() +{ + stop(); +} + +QString AgentKnowledgeServer::serverName() const +{ + return knowledgeServerName(); +} + +void AgentKnowledgeServer::setIgnorePredicate(std::function predicate) +{ + m_ignorePredicate = std::move(predicate); +} + +QStringList AgentKnowledgeServer::toolIds() +{ + return { + Tools::ListOpenEditorsTool().id(), + Tools::GetEditorSelectionTool().id(), + Tools::GetProjectModelTool().id(), + Tools::GetIssuesListTool().id()}; +} + +QString AgentKnowledgeServer::endpointUrl() const +{ + return m_runningPort == 0 + ? QString() + : QStringLiteral("http://127.0.0.1:%1/mcp/%2").arg(m_runningPort).arg(m_pathToken); +} + +QString AgentKnowledgeServer::start() +{ + if (m_runningPort != 0) + return endpointUrl(); + + m_pathToken = QUuid::createUuid().toString(QUuid::WithoutBraces); + + ::LLMQore::Mcp::HttpServerConfig transportConfig; + transportConfig.address = QHostAddress::LocalHost; + transportConfig.port = 0; + transportConfig.path = QStringLiteral("/mcp/%1").arg(m_pathToken); + + m_transport = new ::LLMQore::Mcp::McpHttpServerTransport(transportConfig, this); + + ::LLMQore::Mcp::McpServerConfig serverConfig; + serverConfig.serverInfo = {"QodeAssist", QStringLiteral(LLMQORE_VERSION_STRING)}; + serverConfig.instructions = tr( + "Read-only access to what Qt Creator knows about this session: files open in the editor " + "with their unsaved contents, the current selection, build issues, and the configured " + "kit and build directory. Use it for anything you cannot answer by reading the files on " + "disk yourself."); + + m_server = new ::LLMQore::Mcp::McpServer(m_transport.data(), serverConfig, this); + + auto *openEditors = new Tools::ListOpenEditorsTool(m_server); + openEditors->setIgnorePredicate(m_ignorePredicate); + + m_server->addTool(openEditors); + m_server->addTool(new Tools::GetEditorSelectionTool(m_server)); + m_server->addTool(new Tools::GetProjectModelTool(m_server)); + m_server->addTool(new Tools::GetIssuesListTool(m_server)); + + m_server->start(); + + if (!m_transport->isOpen()) { + LOG_MESSAGE("The QodeAssist knowledge server could not bind a loopback port"); + stop(); + return {}; + } + + m_runningPort = m_transport->serverPort(); + LOG_MESSAGE(QString("QodeAssist knowledge server listening on 127.0.0.1:%1").arg(m_runningPort)); + + return endpointUrl(); +} + +void AgentKnowledgeServer::stop() +{ + if (m_server) { + m_server->stop(); + m_server->deleteLater(); + m_server.clear(); + } + + if (m_transport) { + m_transport->deleteLater(); + m_transport.clear(); + } + + m_runningPort = 0; + m_pathToken.clear(); +} + +} // namespace QodeAssist::Mcp diff --git a/sources/mcp/AgentKnowledgeServer.hpp b/sources/mcp/AgentKnowledgeServer.hpp new file mode 100644 index 0000000..005adcc --- /dev/null +++ b/sources/mcp/AgentKnowledgeServer.hpp @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include +#include +#include + +#include "acp/AgentKnowledgeService.hpp" + +namespace LLMQore::Mcp { +class McpServer; +class McpHttpServerTransport; +} // namespace LLMQore::Mcp + +namespace QodeAssist::Mcp { + +class AgentKnowledgeServer : public QObject, public Acp::AgentKnowledgeService +{ + Q_OBJECT + +public: + explicit AgentKnowledgeServer(QObject *parent = nullptr); + ~AgentKnowledgeServer() override; + + QString start() override; + void stop() override; + QString serverName() const override; + + void setIgnorePredicate(std::function predicate); + + quint16 runningPort() const { return m_runningPort; } + QString endpointUrl() const; + static QStringList toolIds(); + +private: + QPointer<::LLMQore::Mcp::McpServer> m_server; + QPointer<::LLMQore::Mcp::McpHttpServerTransport> m_transport; + std::function m_ignorePredicate; + QString m_pathToken; + quint16 m_runningPort = 0; +}; + +} // namespace QodeAssist::Mcp diff --git a/sources/plugin/qodeassist.cpp b/sources/plugin/qodeassist.cpp index b1fc3dd..f085b6d 100644 --- a/sources/plugin/qodeassist.cpp +++ b/sources/plugin/qodeassist.cpp @@ -60,8 +60,12 @@ #include "templates/Templates.hpp" #include "widgets/CustomInstructionsManager.hpp" #include "widgets/QuickRefactorDialog.hpp" +#include + #include -#include +#include +#include +#include #include #include #include @@ -96,7 +100,7 @@ public: ~QodeAssistPlugin() final { - Chat::ChatFileManager::cleanupGlobalIntermediateStorage(); + Chat::AttachmentStaging::cleanupGlobalIntermediateStorage(); delete m_qodeAssistClient; if (m_chatOutputPane) { @@ -176,6 +180,10 @@ public: m_engine = new QQmlEngine{this}; m_sessionFileRegistry = new Chat::SessionFileRegistry{this}; m_skillsManager = new Skills::SkillsManager{this}; + m_agentCatalog = new Acp::AgentCatalogStore{this}; + m_agentCatalog->reload(); + m_engine->rootContext()->setContextProperty("agentCatalog", m_agentCatalog); + m_agentsSettingsPage = std::make_unique(m_agentCatalog); { auto &providers = Providers::ProvidersManager::instance(); @@ -308,7 +316,7 @@ public: Core::Constants::G_DEFAULT_THREE); } - Chat::ChatFileManager::cleanupGlobalIntermediateStorage(); + Chat::AttachmentStaging::cleanupGlobalIntermediateStorage(); #ifdef WITH_TESTS addTest(); @@ -499,6 +507,8 @@ private: QPointer m_mcpServerManager; QPointer m_engine; QPointer m_skillsManager; + QPointer m_agentCatalog; + std::unique_ptr m_agentsSettingsPage; }; } // namespace QodeAssist::Internal diff --git a/sources/refactor/QuickRefactorHandler.cpp b/sources/refactor/QuickRefactorHandler.cpp index b6cfc10..722286f 100644 --- a/sources/refactor/QuickRefactorHandler.cpp +++ b/sources/refactor/QuickRefactorHandler.cpp @@ -15,7 +15,6 @@ #include #include "templates/PromptTemplateManager.hpp" #include "providers/ProvidersManager.hpp" -#include "context/RulesLoader.hpp" #include #include #include @@ -270,17 +269,6 @@ LLMCore::ContextData QuickRefactorHandler::prepareContext( QString systemPrompt = Settings::quickRefactorSettings().systemPrompt(); - auto project = Context::RulesLoader::getActiveProject(); - if (project) { - QString projectRules = Context::RulesLoader::loadRulesForProject( - project, Context::RulesContext::QuickRefactor); - - if (!projectRules.isEmpty()) { - systemPrompt += "\n\n# Project Rules\n\n" + projectRules; - LOG_MESSAGE("Loaded project rules for quick refactor"); - } - } - systemPrompt += "\n\nFile information:"; systemPrompt += "\nLanguage: " + documentInfo.mimeType; systemPrompt += "\nFile path: " + documentInfo.filePath; diff --git a/sources/session/AgentPlan.cpp b/sources/session/AgentPlan.cpp new file mode 100644 index 0000000..6d59478 --- /dev/null +++ b/sources/session/AgentPlan.cpp @@ -0,0 +1,57 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/AgentPlan.hpp" + +#include + +#include "session/BlockCodec.hpp" + +namespace QodeAssist::Session { + +QJsonObject planBlockToJson(const PlanBlock &block) +{ + QJsonArray entries; + for (const PlanEntry &entry : block.entries) { + QJsonObject json{{"content", entry.content}, {"status", entry.status}}; + if (!entry.priority.isEmpty()) + json["priority"] = entry.priority; + entries.append(json); + } + + return QJsonObject{{"entries", entries}}; +} + +PlanBlock planBlockFromJson(const QJsonObject &json) +{ + PlanBlock block; + + const QJsonArray entries = json["entries"].toArray(); + for (const QJsonValue &value : entries) { + const QJsonObject entry = value.toObject(); + block.entries.append( + PlanEntry{ + entry["content"].toString(), + entry["priority"].toString(), + entry["status"].toString()}); + } + + return block; +} + +QString encodePlanBlock(const PlanBlock &block) +{ + return encodeMarkerPayload(planPayloadMarker, planBlockToJson(block)); +} + +std::optional decodePlanBlock(const QString &text) +{ + const auto payload = decodeMarkerPayload(planPayloadMarker, text); + if (!payload) + return std::nullopt; + + return planBlockFromJson(*payload); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/AgentPlan.hpp b/sources/session/AgentPlan.hpp new file mode 100644 index 0000000..49aa9b1 --- /dev/null +++ b/sources/session/AgentPlan.hpp @@ -0,0 +1,49 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include + +#include + +namespace QodeAssist::Session { + +struct PlanEntry +{ + QString content; + QString priority; + QString status; + + bool operator==(const PlanEntry &other) const = default; + + friend QDebug operator<<(QDebug debug, const PlanEntry &entry) + { + return debug.nospace() << "PlanEntry(" << entry.content << ", " << entry.priority << ", " + << entry.status << ")"; + } +}; + +struct PlanBlock +{ + QList entries; + + bool operator==(const PlanBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const PlanBlock &block) + { + return debug.nospace() << "Plan(" << block.entries << ")"; + } +}; + +QJsonObject planBlockToJson(const PlanBlock &block); +PlanBlock planBlockFromJson(const QJsonObject &json); + +QString encodePlanBlock(const PlanBlock &block); +std::optional decodePlanBlock(const QString &text); + +} // namespace QodeAssist::Session diff --git a/sources/session/BlockCodec.cpp b/sources/session/BlockCodec.cpp new file mode 100644 index 0000000..1534afe --- /dev/null +++ b/sources/session/BlockCodec.cpp @@ -0,0 +1,38 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/BlockCodec.hpp" + +#include + +namespace QodeAssist::Session { + +QList knownPayloadMarkers() +{ + return {payloadMarkers.begin(), payloadMarkers.end()}; +} + +bool hasPayloadMarker(QLatin1StringView marker, const QString &text) +{ + return text.startsWith(marker); +} + +QString encodeMarkerPayload(QLatin1StringView marker, const QJsonObject &payload) +{ + return marker + QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact)); +} + +std::optional decodeMarkerPayload(QLatin1StringView marker, const QString &text) +{ + if (!hasPayloadMarker(marker, text)) + return std::nullopt; + + const QJsonDocument document = QJsonDocument::fromJson(text.mid(marker.size()).toUtf8()); + if (!document.isObject()) + return std::nullopt; + + return document.object(); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/BlockCodec.hpp b/sources/session/BlockCodec.hpp new file mode 100644 index 0000000..acec074 --- /dev/null +++ b/sources/session/BlockCodec.hpp @@ -0,0 +1,32 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace QodeAssist::Session { + +inline constexpr std::array payloadMarkers{ + QLatin1StringView{"QODEASSIST_FILE_EDIT:"}, + QLatin1StringView{"QODEASSIST_PERMISSION:"}, + QLatin1StringView{"QODEASSIST_PLAN:"}}; + +inline constexpr QLatin1StringView fileEditPayloadMarker = payloadMarkers[0]; +inline constexpr QLatin1StringView permissionPayloadMarker = payloadMarkers[1]; +inline constexpr QLatin1StringView planPayloadMarker = payloadMarkers[2]; + +QList knownPayloadMarkers(); + +bool hasPayloadMarker(QLatin1StringView marker, const QString &text); +QString encodeMarkerPayload(QLatin1StringView marker, const QJsonObject &payload); +std::optional decodeMarkerPayload(QLatin1StringView marker, const QString &text); + +} // namespace QodeAssist::Session diff --git a/sources/session/CMakeLists.txt b/sources/session/CMakeLists.txt index f9f3aa4..b428bd3 100644 --- a/sources/session/CMakeLists.txt +++ b/sources/session/CMakeLists.txt @@ -1,11 +1,16 @@ add_library(QodeAssistSession STATIC + AgentPlan.hpp AgentPlan.cpp + BlockCodec.hpp BlockCodec.cpp ContentBlock.hpp Message.hpp ConversationHistory.hpp ConversationHistory.cpp + FencedText.hpp FencedText.cpp FileEditPayload.hpp FileEditPayload.cpp HistoryProjection.hpp HistoryProjection.cpp HistorySerializer.hpp HistorySerializer.cpp + PermissionRequest.hpp PermissionRequest.cpp SessionEvent.hpp SessionEvent.cpp + TurnLedger.hpp TurnLedger.cpp TurnRequest.hpp ChatBackend.hpp Session.hpp Session.cpp diff --git a/sources/session/ChatBackend.hpp b/sources/session/ChatBackend.hpp index 7b7d93e..e3b07a3 100644 --- a/sources/session/ChatBackend.hpp +++ b/sources/session/ChatBackend.hpp @@ -21,6 +21,18 @@ public: virtual void sendTurn(const TurnRequest &request) = 0; virtual void cancel() = 0; + virtual bool respondPermission(const QString &requestId, const QString &optionId) + { + Q_UNUSED(requestId) + Q_UNUSED(optionId) + return false; + } + + virtual TurnContextNeeds contextNeeds() const { return {}; } + + virtual void setChatFilePath(const QString &filePath) { Q_UNUSED(filePath) } + virtual void clearToolSession(const QString &filePath) { Q_UNUSED(filePath) } + signals: void sessionEvent(const QodeAssist::Session::SessionEvent &event); }; diff --git a/sources/session/ContentBlock.hpp b/sources/session/ContentBlock.hpp index baed900..7c2afed 100644 --- a/sources/session/ContentBlock.hpp +++ b/sources/session/ContentBlock.hpp @@ -10,6 +10,9 @@ #include +#include "session/AgentPlan.hpp" +#include "session/PermissionRequest.hpp" + namespace QodeAssist::Session { struct TextBlock @@ -45,6 +48,10 @@ struct ToolCallBlock QString name; QJsonObject arguments; QString result; + QString kind; + QString status; + QJsonObject details; + bool fromAgent = false; bool operator==(const ToolCallBlock &other) const = default; @@ -52,7 +59,9 @@ struct ToolCallBlock { return debug.nospace() << "ToolCall(" << block.id << ", " << block.name << ", args=" << block.arguments << ", result=" << block.result - << ")"; + << ", kind=" << block.kind << ", status=" << block.status + << ", details=" << block.details + << ", fromAgent=" << block.fromAgent << ")"; } }; @@ -98,8 +107,15 @@ struct FileEditBlock } }; -using ContentBlock = std:: - variant; +using ContentBlock = std::variant< + TextBlock, + ThinkingBlock, + ToolCallBlock, + AttachmentBlock, + ImageBlock, + FileEditBlock, + PermissionBlock, + PlanBlock>; inline QDebug operator<<(QDebug debug, const ContentBlock &block) { diff --git a/sources/session/FencedText.cpp b/sources/session/FencedText.cpp new file mode 100644 index 0000000..304a633 --- /dev/null +++ b/sources/session/FencedText.cpp @@ -0,0 +1,36 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/FencedText.hpp" + +namespace QodeAssist::Session { + +namespace { + +constexpr int minimumFenceLength = 3; + +int longestBacktickRun(const QString &content) +{ + int longest = 0; + int current = 0; + + for (const QChar character : content) { + current = character == QLatin1Char('`') ? current + 1 : 0; + longest = qMax(longest, current); + } + + return longest; +} + +} // namespace + +QString fencedFileBlock(const QString &fileName, const QString &content) +{ + const int fenceLength = qMax(minimumFenceLength, longestBacktickRun(content) + 1); + const QString fence(fenceLength, QLatin1Char('`')); + + return QStringLiteral("File: %1\n%2\n%3\n%2").arg(fileName, fence, content); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/FencedText.hpp b/sources/session/FencedText.hpp new file mode 100644 index 0000000..7a7a374 --- /dev/null +++ b/sources/session/FencedText.hpp @@ -0,0 +1,13 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist::Session { + +QString fencedFileBlock(const QString &fileName, const QString &content); + +} // namespace QodeAssist::Session diff --git a/sources/session/FileEditPayload.cpp b/sources/session/FileEditPayload.cpp index cda1b8c..6ea2983 100644 --- a/sources/session/FileEditPayload.cpp +++ b/sources/session/FileEditPayload.cpp @@ -4,46 +4,23 @@ #include "session/FileEditPayload.hpp" -#include +#include "session/BlockCodec.hpp" namespace QodeAssist::Session { -namespace { - -QString fileEditMarker() -{ - return QStringLiteral("QODEASSIST_FILE_EDIT:"); -} - -} // namespace - bool isFileEditPayload(const QString &text) { - return text.startsWith(fileEditMarker()); + return hasPayloadMarker(fileEditPayloadMarker, text); } std::optional parseFileEditPayload(const QString &text) { - const QString marker = fileEditMarker(); - const int markerPos = text.indexOf(marker); - if (markerPos < 0) - return std::nullopt; - - const int jsonStart = markerPos + marker.length(); - if (jsonStart >= text.length()) - return std::nullopt; - - const QJsonDocument document = QJsonDocument::fromJson(text.mid(jsonStart).toUtf8()); - if (!document.isObject()) - return std::nullopt; - - return document.object(); + return decodeMarkerPayload(fileEditPayloadMarker, text); } QString encodeFileEditPayload(const QJsonObject &payload) { - return fileEditMarker() - + QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact)); + return encodeMarkerPayload(fileEditPayloadMarker, payload); } } // namespace QodeAssist::Session diff --git a/sources/session/HistoryProjection.cpp b/sources/session/HistoryProjection.cpp index b39f9d7..f75d344 100644 --- a/sources/session/HistoryProjection.cpp +++ b/sources/session/HistoryProjection.cpp @@ -7,6 +7,12 @@ #include #include +#if defined(Q_CC_MSVC) +#pragma warning(error : 4062) +#else +#pragma GCC diagnostic error "-Wswitch" +#endif + namespace QodeAssist::Session { namespace { @@ -52,7 +58,15 @@ QString toolDisplayText(const ToolCallBlock &block) ToolCallBlock toolCallOf(const MessageRow &row) { - ToolCallBlock block{row.id, row.toolName, row.toolArguments, row.toolResult}; + ToolCallBlock block{ + row.id, + row.toolName, + row.toolArguments, + row.toolResult, + row.toolKind, + restoredToolStatus(row.toolStatus), + row.toolDetails, + row.kind == RowKind::AgentTool}; if (block.name.isEmpty() && block.result.isEmpty()) block.result = row.content; return block; @@ -73,8 +87,87 @@ RowKind textRowKind(MessageRole role) } // namespace +bool isTerminalToolStatus(const QString &status) +{ + return status.isEmpty() || status == QLatin1String("completed") + || status == QLatin1String("failed") || status == QLatin1String("interrupted"); +} + +QString restoredToolStatus(QString status) +{ + if (!isTerminalToolStatus(status)) + return QStringLiteral("interrupted"); + return status; +} + +bool isTranscriptOnlyRow(RowKind kind) +{ + switch (kind) { + case RowKind::AgentTool: + case RowKind::FileEdit: + case RowKind::Permission: + case RowKind::Plan: + return true; + case RowKind::User: + case RowKind::Assistant: + case RowKind::System: + case RowKind::Tool: + case RowKind::Thinking: + return false; + } + return false; +} + +RowTreatment rowTreatmentFor(RowAudience audience, RowKind kind) +{ + switch (audience) { + case RowAudience::Prompt: + switch (kind) { + case RowKind::User: + return RowTreatment::UserText; + case RowKind::Assistant: + case RowKind::System: + return RowTreatment::AssistantText; + case RowKind::Thinking: + return RowTreatment::AssistantThinking; + case RowKind::Tool: + return RowTreatment::ToolExchange; + case RowKind::AgentTool: + case RowKind::FileEdit: + case RowKind::Permission: + case RowKind::Plan: + return RowTreatment::Omit; + } + break; + case RowAudience::Compression: + switch (kind) { + case RowKind::User: + return RowTreatment::UserText; + case RowKind::Assistant: + case RowKind::System: + return RowTreatment::AssistantText; + case RowKind::Thinking: + case RowKind::Tool: + case RowKind::AgentTool: + case RowKind::FileEdit: + case RowKind::Permission: + case RowKind::Plan: + return RowTreatment::Omit; + } + break; + case RowAudience::TokenCount: + return rowTreatmentFor(RowAudience::Prompt, kind); + } + return RowTreatment::Omit; +} + std::optional projectBlockToRow(const Message &message, const ContentBlock &block) { + static_assert( + std::variant_size_v == 8, + "ContentBlock gained an alternative; extend the projection below or the block lives in the " + "history without ever rendering"); + if (const auto *text = std::get_if(&block)) { MessageRow row; row.kind = textRowKind(message.role); @@ -95,12 +188,15 @@ std::optional projectBlockToRow(const Message &message, const Conten if (const auto *tool = std::get_if(&block)) { MessageRow row; - row.kind = RowKind::Tool; + row.kind = tool->fromAgent ? RowKind::AgentTool : RowKind::Tool; row.id = tool->id; row.content = toolDisplayText(*tool); row.toolName = tool->name; row.toolArguments = tool->arguments; row.toolResult = tool->result; + row.toolKind = tool->kind; + row.toolStatus = tool->status; + row.toolDetails = tool->details; return row; } @@ -112,6 +208,22 @@ std::optional projectBlockToRow(const Message &message, const Conten return row; } + if (const auto *permission = std::get_if(&block)) { + MessageRow row; + row.kind = RowKind::Permission; + row.id = permission->requestId; + row.content = encodePermissionBlock(*permission); + return row; + } + + if (const auto *plan = std::get_if(&block)) { + MessageRow row; + row.kind = RowKind::Plan; + row.id = message.id; + row.content = encodePlanBlock(*plan); + return row; + } + return std::nullopt; } @@ -123,7 +235,7 @@ QList projectMessageToRows(const Message &message) qsizetype textRowIndex = -1; auto appendRow = [&](MessageRow row) { - if (!usageAssigned && row.id == message.id) { + if (!usageAssigned && row.id == message.id && !isTranscriptOnlyRow(row.kind)) { row.usage = message.usage; usageAssigned = true; } @@ -223,7 +335,8 @@ ConversationHistory buildFromRows(const QList &rows) message.usage = row.usage; break; } - case RowKind::Tool: { + case RowKind::Tool: + case RowKind::AgentTool: { Message &message = openAssistant(); message.blocks.append(toolCallOf(row)); break; @@ -233,6 +346,26 @@ ConversationHistory buildFromRows(const QList &rows) message.blocks.append(FileEditBlock{row.id, row.content}); break; } + case RowKind::Permission: { + Message &message = openAssistant(); + if (auto permission = decodePermissionBlock(row.content)) + message.blocks.append(restoredPermissionBlock(*permission)); + else + message.blocks.append(TextBlock{row.content}); + break; + } + case RowKind::Plan: { + Message &message = openAssistant(); + if (message.id.isEmpty()) + message.id = row.id; + if (!row.usage.isEmpty()) + message.usage = row.usage; + if (auto plan = decodePlanBlock(row.content)) + message.blocks.append(*plan); + else + message.blocks.append(TextBlock{row.content}); + break; + } } } diff --git a/sources/session/HistoryProjection.hpp b/sources/session/HistoryProjection.hpp index 5ed421c..07d356f 100644 --- a/sources/session/HistoryProjection.hpp +++ b/sources/session/HistoryProjection.hpp @@ -14,7 +14,17 @@ namespace QodeAssist::Session { -enum class RowKind { User, Assistant, System, Tool, FileEdit, Thinking }; +enum class RowKind { + User, + Assistant, + System, + Tool, + AgentTool, + FileEdit, + Thinking, + Permission, + Plan +}; struct MessageRow { @@ -26,6 +36,9 @@ struct MessageRow QString toolName; QJsonObject toolArguments; QString toolResult; + QString toolKind; + QString toolStatus; + QJsonObject toolDetails; QList attachments; QList images; Usage usage; @@ -38,11 +51,23 @@ struct MessageRow << ", content=" << row.content << ", sig=" << row.signature << ", redacted=" << row.redacted << ", tool=" << row.toolName << ", args=" << row.toolArguments << ", result=" << row.toolResult + << ", toolKind=" << row.toolKind << ", toolStatus=" << row.toolStatus + << ", toolDetails=" << row.toolDetails << ", attachments=" << row.attachments << ", images=" << row.images << ", " << row.usage << ")"; } }; +enum class RowAudience { Prompt, Compression, TokenCount }; + +enum class RowTreatment { Omit, UserText, AssistantText, AssistantThinking, ToolExchange }; + +bool isTranscriptOnlyRow(RowKind kind); +RowTreatment rowTreatmentFor(RowAudience audience, RowKind kind); + +bool isTerminalToolStatus(const QString &status); +QString restoredToolStatus(QString status); + std::optional projectBlockToRow(const Message &message, const ContentBlock &block); QList projectMessageToRows(const Message &message); QList projectToRows(const ConversationHistory &history); diff --git a/sources/session/HistorySerializer.cpp b/sources/session/HistorySerializer.cpp index 7b5d241..812d986 100644 --- a/sources/session/HistorySerializer.cpp +++ b/sources/session/HistorySerializer.cpp @@ -78,6 +78,11 @@ Usage usageFromJson(const QJsonObject &json) QJsonObject blockToJson(const ContentBlock &block) { + static_assert( + std::variant_size_v == 8, + "ContentBlock gained an alternative; extend blockToJson and blockFromJson below or it is " + "written without a type and dropped on the next load"); + QJsonObject json; if (const auto *text = std::get_if(&block)) { @@ -98,6 +103,14 @@ QJsonObject blockToJson(const ContentBlock &block) json["arguments"] = tool->arguments; if (!tool->result.isEmpty()) json["result"] = tool->result; + if (!tool->kind.isEmpty()) + json["kind"] = tool->kind; + if (!tool->status.isEmpty()) + json["status"] = tool->status; + if (!tool->details.isEmpty()) + json["details"] = tool->details; + if (tool->fromAgent) + json["fromAgent"] = true; } else if (const auto *attachment = std::get_if(&block)) { json["type"] = "attachment"; json["fileName"] = attachment->fileName; @@ -111,6 +124,12 @@ QJsonObject blockToJson(const ContentBlock &block) json["type"] = "file_edit"; json["id"] = edit->id; json["payload"] = edit->payload; + } else if (const auto *permission = std::get_if(&block)) { + json = permissionBlockToJson(*permission); + json["type"] = "permission"; + } else if (const auto *plan = std::get_if(&block)) { + json = planBlockToJson(*plan); + json["type"] = "plan"; } return json; @@ -133,7 +152,11 @@ std::optional blockFromJson(const QJsonObject &json) json["id"].toString(), json["name"].toString(), json["arguments"].toObject(), - json["result"].toString()}}; + json["result"].toString(), + json["kind"].toString(), + restoredToolStatus(json["status"].toString()), + json["details"].toObject(), + json["fromAgent"].toBool(false)}}; } if (type == QLatin1String("attachment")) @@ -150,6 +173,12 @@ std::optional blockFromJson(const QJsonObject &json) if (type == QLatin1String("file_edit")) return ContentBlock{FileEditBlock{json["id"].toString(), json["payload"].toString()}}; + if (type == QLatin1String("permission")) + return ContentBlock{restoredPermissionBlock(permissionBlockFromJson(json))}; + + if (type == QLatin1String("plan")) + return ContentBlock{planBlockFromJson(json)}; + return std::nullopt; } diff --git a/sources/session/PermissionRequest.cpp b/sources/session/PermissionRequest.cpp new file mode 100644 index 0000000..6f06dd7 --- /dev/null +++ b/sources/session/PermissionRequest.cpp @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/PermissionRequest.hpp" + +#include + +#include "session/BlockCodec.hpp" + +namespace QodeAssist::Session { + +QString permissionStatusToString(PermissionStatus status) +{ + switch (status) { + case PermissionStatus::Pending: + return QStringLiteral("pending"); + case PermissionStatus::Answered: + return QStringLiteral("answered"); + case PermissionStatus::Cancelled: + return QStringLiteral("cancelled"); + } + return QStringLiteral("pending"); +} + +PermissionStatus permissionStatusFromString(const QString &status) +{ + if (status == QLatin1String("answered")) + return PermissionStatus::Answered; + if (status == QLatin1String("cancelled")) + return PermissionStatus::Cancelled; + return PermissionStatus::Pending; +} + +std::optional PermissionBlock::option(const QString &optionId) const +{ + for (const PermissionOption &candidate : options) { + if (candidate.id == optionId) + return candidate; + } + return std::nullopt; +} + +QJsonObject permissionBlockToJson(const PermissionBlock &block) +{ + QJsonArray options; + for (const PermissionOption &option : block.options) { + options.append( + QJsonObject{ + {"id", option.id}, + {"name", option.name}, + {"kind", option.kind}, + {"allows", option.allows()}}); + } + + QJsonObject json; + json["requestId"] = block.requestId; + json["title"] = block.title; + json["options"] = options; + json["status"] = permissionStatusToString(block.status); + if (!block.toolCallId.isEmpty()) + json["toolCallId"] = block.toolCallId; + if (!block.toolKind.isEmpty()) + json["toolKind"] = block.toolKind; + if (!block.selectedOptionId.isEmpty()) + json["selectedOptionId"] = block.selectedOptionId; + if (block.automatic) + json["automatic"] = true; + + return json; +} + +PermissionBlock permissionBlockFromJson(const QJsonObject &json) +{ + PermissionBlock block; + block.requestId = json["requestId"].toString(); + block.toolCallId = json["toolCallId"].toString(); + block.title = json["title"].toString(); + block.toolKind = json["toolKind"].toString(); + block.status = permissionStatusFromString(json["status"].toString()); + block.selectedOptionId = json["selectedOptionId"].toString(); + block.automatic = json["automatic"].toBool(false); + + const QJsonArray options = json["options"].toArray(); + for (const QJsonValue &value : options) { + const QJsonObject option = value.toObject(); + block.options.append( + PermissionOption{ + option["id"].toString(), option["name"].toString(), option["kind"].toString()}); + } + + return block; +} + +PermissionBlock restoredPermissionBlock(PermissionBlock block) +{ + if (block.status == PermissionStatus::Pending) + block.status = PermissionStatus::Cancelled; + return block; +} + +QString encodePermissionBlock(const PermissionBlock &block) +{ + return encodeMarkerPayload(permissionPayloadMarker, permissionBlockToJson(block)); +} + +std::optional decodePermissionBlock(const QString &text) +{ + const auto payload = decodeMarkerPayload(permissionPayloadMarker, text); + if (!payload) + return std::nullopt; + + return permissionBlockFromJson(*payload); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/PermissionRequest.hpp b/sources/session/PermissionRequest.hpp new file mode 100644 index 0000000..e472aa3 --- /dev/null +++ b/sources/session/PermissionRequest.hpp @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include + +#include + +namespace QodeAssist::Session { + +namespace PermissionOptionKind { +inline constexpr QLatin1StringView AllowOnce{"allow_once"}; +inline constexpr QLatin1StringView AllowAlways{"allow_always"}; +inline constexpr QLatin1StringView RejectOnce{"reject_once"}; +inline constexpr QLatin1StringView RejectAlways{"reject_always"}; +} // namespace PermissionOptionKind + +struct PermissionOption +{ + QString id; + QString name; + QString kind; + + bool allows() const + { + return kind == PermissionOptionKind::AllowOnce || kind == PermissionOptionKind::AllowAlways; + } + + bool scopesToConversation() const + { + return kind == PermissionOptionKind::AllowAlways + || kind == PermissionOptionKind::RejectAlways; + } + + bool operator==(const PermissionOption &other) const = default; + + friend QDebug operator<<(QDebug debug, const PermissionOption &option) + { + return debug.nospace() << "Option(" << option.id << ", " << option.name << ", " + << option.kind << ")"; + } +}; + +enum class PermissionStatus { Pending, Answered, Cancelled }; + +QString permissionStatusToString(PermissionStatus status); +PermissionStatus permissionStatusFromString(const QString &status); + +struct PermissionBlock +{ + QString requestId; + QString toolCallId; + QString title; + QString toolKind; + QList options; + PermissionStatus status = PermissionStatus::Pending; + QString selectedOptionId; + bool automatic = false; + + std::optional option(const QString &optionId) const; + + bool operator==(const PermissionBlock &other) const = default; + + friend QDebug operator<<(QDebug debug, const PermissionBlock &block) + { + return debug.nospace() << "Permission(" << block.requestId << ", " << block.title + << ", kind=" << block.toolKind << ", options=" << block.options + << ", status=" << permissionStatusToString(block.status) + << ", selected=" << block.selectedOptionId + << ", automatic=" << block.automatic << ")"; + } +}; + +QJsonObject permissionBlockToJson(const PermissionBlock &block); +PermissionBlock permissionBlockFromJson(const QJsonObject &json); + +PermissionBlock restoredPermissionBlock(PermissionBlock block); + +QString encodePermissionBlock(const PermissionBlock &block); +std::optional decodePermissionBlock(const QString &text); + +} // namespace QodeAssist::Session diff --git a/sources/session/Session.cpp b/sources/session/Session.cpp index 51f2197..cd2b3ba 100644 --- a/sources/session/Session.cpp +++ b/sources/session/Session.cpp @@ -4,6 +4,7 @@ #include "session/Session.hpp" +#include #include #include @@ -68,8 +69,10 @@ void Session::setBackend(ChatBackend *backend) if (m_backend == backend) return; - if (m_backend) + if (m_backend) { + cancel(); disconnect(m_backend, nullptr, this, nullptr); + } m_backend = backend; @@ -91,6 +94,9 @@ const QList &Session::rows() const void Session::setHistory(const ConversationHistory &history) { cancel(); + m_pendingPermissions.clear(); + m_alwaysAllowedToolKinds.clear(); + m_alwaysRejectedToolKinds.clear(); m_history = history; m_rows = projectToRows(m_history); emit rowsReset(m_rows); @@ -131,9 +137,7 @@ void Session::truncateRows(int rowIndex) } void Session::sendTurn( - const QList &userBlocks, - const std::optional &context, - const TurnOptions &options) + const QList &userBlocks, const std::optional &context) { if (!m_backend) return; @@ -149,7 +153,6 @@ void Session::sendTurn( request.userBlocks = userBlocks; request.history = &m_history; request.context = context; - request.options = options; m_backend->sendTurn(request); } @@ -162,6 +165,378 @@ void Session::cancel() m_backend->cancel(); } +bool Session::isPermissionPending(const QString &requestId) const +{ + return m_pendingPermissions.contains(requestId); +} + +void Session::respondPermission(const QString &requestId, const QString &optionId) +{ + if (!isPermissionPending(requestId)) { + qWarning("QodeAssist: ignoring an answer to permission request %s, which is not pending", + qUtf8Printable(requestId)); + return; + } + + const std::optional block = permissionBlock(requestId); + if (!block) { + qWarning("QodeAssist: permission request %s has no record to answer", + qUtf8Printable(requestId)); + applyPermissionResolution( + PermissionResolved{.turnId = m_activeTurnId, .requestId = requestId, .cancelled = true}); + return; + } + + if (!block->option(optionId)) { + qWarning("QodeAssist: permission request %s was not offered option %s", + qUtf8Printable(requestId), qUtf8Printable(optionId)); + return; + } + + if (m_backend && m_backend->respondPermission(requestId, optionId)) + return; + + qWarning("QodeAssist: no backend accepted the answer to permission request %s", + qUtf8Printable(requestId)); + applyPermissionResolution( + PermissionResolved{.turnId = m_activeTurnId, .requestId = requestId, .cancelled = true}); +} + +std::optional Session::permissionBlock(const QString &requestId) const +{ + const QList &messages = m_history.messages(); + + for (auto message = messages.crbegin(); message != messages.crend(); ++message) { + for (auto block = message->blocks.crbegin(); block != message->blocks.crend(); ++block) { + if (const auto *permission = std::get_if(&*block); + permission && permission->requestId == requestId) { + return *permission; + } + } + } + + return std::nullopt; +} + +QString Session::autoAnswerOptionFor(const PermissionRequested &request) const +{ + if (request.toolKind.isEmpty()) + return {}; + + const bool rejected = m_alwaysRejectedToolKinds.contains(request.toolKind); + if (!rejected && !m_alwaysAllowedToolKinds.contains(request.toolKind)) + return {}; + + const QLatin1StringView once = rejected ? PermissionOptionKind::RejectOnce + : PermissionOptionKind::AllowOnce; + const QLatin1StringView always = rejected ? PermissionOptionKind::RejectAlways + : PermissionOptionKind::AllowAlways; + + QString fallback; + for (const PermissionOption &option : request.options) { + if (option.kind == once) + return option.id; + if (option.kind == always && fallback.isEmpty()) + fallback = option.id; + } + + return fallback; +} + +bool Session::refreshAssistantBlockRow( + const ContentBlock &block, RowKind kind, const QString &rowId) +{ + const Message *assistant = activeAssistantMessage(); + if (!assistant || m_assistantRowStart < 0) + return false; + + auto fresh = projectBlockToRow(*assistant, block); + if (!fresh) + return false; + + for (int i = static_cast(m_rows.size()) - 1; i >= m_assistantRowStart; --i) { + if (m_rows[i].kind != kind || m_rows[i].id != rowId) + continue; + + fresh->usage = m_rows.at(i).usage; + if (m_rows.at(i) == *fresh) + return true; + + m_rows[i] = *fresh; + emit rowUpdated(i, *fresh); + return true; + } + + return false; +} + +void Session::applyToolCall(const ToolCallUpdated &update) +{ + ensureAssistantMessage(); + + Message *assistant = activeAssistantMessage(); + if (!assistant) + return; + + const auto hasFileEdit = [assistant](const QString &editId) { + for (const ContentBlock &block : assistant->blocks) { + const auto *existing = std::get_if(&block); + if (existing && existing->id == editId) + return true; + } + return false; + }; + + struct RecordedAgentEdit + { + QString editId; + QString filePath; + QString oldContent; + QString newContent; + }; + + const auto appendAgentEdits = + [assistant, &hasFileEdit](const ToolCallBlock &tool) -> QList { + QList recorded; + if (tool.status != QLatin1String("completed")) + return recorded; + + const QJsonArray diffs = tool.details.value("diffs").toArray(); + for (qsizetype i = 0; i < diffs.size(); ++i) { + const QJsonObject diff = diffs.at(i).toObject(); + const QString path = diff.value("path").toString(); + const QString oldText = diff.value("oldText").toString(); + const QString newText = diff.value("newText").toString(); + if (path.isEmpty() || (oldText.isEmpty() && newText.isEmpty())) + continue; + + const QString editId = QString("agent-%1-%2").arg(tool.id).arg(i); + if (hasFileEdit(editId)) + continue; + + const QJsonObject payload{ + {"edit_id", editId}, + {"file", path}, + {"old_content", oldText}, + {"new_content", newText}, + {"status", "applied"}, + {"status_message", "Applied by agent"}}; + assistant->blocks.append( + FileEditBlock{.id = editId, .payload = encodeFileEditPayload(payload)}); + + const bool revertable = !newText.isEmpty() + && !diff.value("truncated").toBool(false); + if (revertable) + recorded.append({editId, path, oldText, newText}); + } + return recorded; + }; + + for (auto it = assistant->blocks.rbegin(); it != assistant->blocks.rend(); ++it) { + auto *tool = std::get_if(&*it); + if (!tool || tool->id != update.toolId) + continue; + + if (!update.name.isEmpty()) + tool->name = update.name; + if (!update.kind.isEmpty()) + tool->kind = update.kind; + if (!update.status.isEmpty()) + tool->status = update.status; + if (!update.arguments.isEmpty()) + tool->arguments = update.arguments; + if (!update.details.isEmpty()) + tool->details = update.details; + if (!update.result.isEmpty()) + tool->result = update.result; + + const bool agentTool = tool->fromAgent; + if (!agentTool) { + if (const auto edit = fileEditFromToolResult(update.result); + edit && !hasFileEdit(edit->id)) { + m_textSegment.clear(); + assistant->blocks.append(*edit); + syncAssistantRows(); + return; + } + } else { + const ToolCallBlock snapshot = *tool; + const qsizetype blocksBefore = assistant->blocks.size(); + const QList recorded = appendAgentEdits(snapshot); + if (assistant->blocks.size() != blocksBefore) { + m_textSegment.clear(); + syncAssistantRows(); + for (const RecordedAgentEdit &edit : recorded) { + emit agentFileEditRecorded( + m_activeTurnId, + edit.editId, + edit.filePath, + edit.oldContent, + edit.newContent); + } + return; + } + } + + const RowKind rowKind = agentTool ? RowKind::AgentTool : RowKind::Tool; + const QString rowId = update.toolId; + if (!refreshAssistantBlockRow(*it, rowKind, rowId)) + syncAssistantRows(); + return; + } + + m_textSegment.clear(); + + if (update.dropPrecedingText && !assistant->blocks.isEmpty() + && std::get_if(&assistant->blocks.last())) { + assistant->blocks.removeLast(); + } + + const ToolCallBlock appended{ + .id = update.toolId, + .name = update.name, + .arguments = update.arguments, + .result = update.result, + .kind = update.kind, + .status = update.status, + .details = update.details, + .fromAgent = update.fromAgent}; + assistant->blocks.append(appended); + + QList recorded; + if (update.fromAgent) { + recorded = appendAgentEdits(appended); + } else if (const auto edit = fileEditFromToolResult(update.result)) { + assistant->blocks.append(*edit); + } + + syncAssistantRows(); + + for (const RecordedAgentEdit &edit : recorded) { + emit agentFileEditRecorded( + m_activeTurnId, edit.editId, edit.filePath, edit.oldContent, edit.newContent); + } +} + +void Session::applyAgentPlan(const PlanUpdated &plan) +{ + ensureAssistantMessage(); + + Message *assistant = activeAssistantMessage(); + if (!assistant) + return; + + for (ContentBlock &block : assistant->blocks) { + auto *existing = std::get_if(&block); + if (!existing) + continue; + + existing->entries = plan.entries; + if (!refreshAssistantBlockRow(block, RowKind::Plan, assistant->id)) + syncAssistantRows(); + return; + } + + if (plan.entries.isEmpty()) + return; + + m_textSegment.clear(); + assistant->blocks.append(PlanBlock{plan.entries}); + syncAssistantRows(); +} + +void Session::applyPermissionRequest(const PermissionRequested &request) +{ + const QString autoOptionId = autoAnswerOptionFor(request); + + m_textSegment.clear(); + m_pendingPermissions.insert(request.requestId); + + mutateAssistant([&request, &autoOptionId](Message &message) { + message.blocks.append( + PermissionBlock{ + .requestId = request.requestId, + .toolCallId = request.toolCallId, + .title = request.title, + .toolKind = request.toolKind, + .options = request.options, + .status = PermissionStatus::Pending, + .selectedOptionId = {}, + .automatic = !autoOptionId.isEmpty()}); + }); + + if (autoOptionId.isEmpty()) + return; + + if (m_backend) + m_backend->respondPermission(request.requestId, autoOptionId); + else + applyPermissionResolution( + PermissionResolved{ + .turnId = request.turnId, .requestId = request.requestId, .cancelled = true}); +} + +void Session::applyPermissionResolution(const PermissionResolved &resolution) +{ + if (!m_pendingPermissions.remove(resolution.requestId)) + return; + + QString grantedToolKind; + bool grantAllows = false; + + mutatePermissionBlock(resolution.requestId, [&](PermissionBlock &block) { + block.status = resolution.cancelled ? PermissionStatus::Cancelled + : PermissionStatus::Answered; + block.selectedOptionId = resolution.cancelled ? QString() : resolution.optionId; + if (resolution.cancelled) { + block.automatic = false; + return; + } + + const std::optional option = block.option(resolution.optionId); + if (option && option->scopesToConversation() && !block.toolKind.isEmpty()) { + grantedToolKind = block.toolKind; + grantAllows = option->allows(); + } + }); + + if (grantedToolKind.isEmpty()) + return; + + if (grantAllows) + m_alwaysAllowedToolKinds.insert(grantedToolKind); + else + m_alwaysRejectedToolKinds.insert(grantedToolKind); +} + +void Session::mutatePermissionBlock( + const QString &requestId, const std::function &mutate) +{ + PermissionBlock *target = nullptr; + + m_history.visitBlocks([&](ContentBlock &block) { + if (auto *permission = std::get_if(&block); + permission && permission->requestId == requestId) { + target = permission; + } + }); + + if (!target) + return; + + mutate(*target); + const QString encodedPayload = encodePermissionBlock(*target); + + for (int i = static_cast(m_rows.size()) - 1; i >= 0; --i) { + if (m_rows[i].kind != RowKind::Permission || m_rows[i].id != requestId) + continue; + m_rows[i].content = encodedPayload; + const MessageRow updated = m_rows.at(i); + emit rowUpdated(i, updated); + return; + } +} + bool Session::updateFileEditStatus( const QString &editId, const QString &status, const QString &statusMessage) { @@ -202,7 +577,7 @@ bool Session::updateFileEditStatus( void Session::handleEvent(const SessionEvent &event) { static_assert( - std::variant_size_v == 8, + std::variant_size_v == 11, "SessionEvent gained an alternative; extend the dispatch below or it is silently dropped"); if (const auto *started = std::get_if(&event)) { @@ -212,6 +587,19 @@ void Session::handleEvent(const SessionEvent &event) return; } + if (const auto *resolution = std::get_if(&event)) { + applyPermissionResolution(*resolution); + return; + } + + if (const auto *info = std::get_if(&event)) { + constexpr qsizetype maxSessionTitleLength = 60; + const QString title = info->title.simplified().left(maxSessionTitleLength); + if (!title.isEmpty()) + emit sessionInfoReceived(title); + return; + } + if (const auto *failure = std::get_if(&event)) { if (!failure->turnId.isEmpty() && failure->turnId != m_activeTurnId) return; @@ -265,34 +653,12 @@ void Session::handleEvent(const SessionEvent &event) .signature = thinking->signature, .redacted = thinking->redacted}); }); - } else if (const auto *toolStart = std::get_if(&event)) { - m_textSegment.clear(); - mutateAssistant([toolStart](Message &message) { - if (toolStart->dropPrecedingText && !message.blocks.isEmpty() - && std::holds_alternative(message.blocks.last())) { - message.blocks.removeLast(); - } - message.blocks.append( - ToolCallBlock{ - .id = toolStart->toolId, - .name = toolStart->name, - .arguments = toolStart->arguments, - .result = {}}); - }); - } else if (const auto *toolEnd = std::get_if(&event)) { - m_textSegment.clear(); - mutateAssistant([toolEnd](Message &message) { - for (auto it = message.blocks.rbegin(); it != message.blocks.rend(); ++it) { - auto *tool = std::get_if(&*it); - if (!tool || tool->id != toolEnd->toolId) - continue; - tool->name = toolEnd->name; - tool->result = toolEnd->result; - break; - } - if (const auto edit = fileEditFromToolResult(toolEnd->result)) - message.blocks.append(*edit); - }); + } else if (const auto *toolUpdate = std::get_if(&event)) { + applyToolCall(*toolUpdate); + } else if (const auto *plan = std::get_if(&event)) { + applyAgentPlan(*plan); + } else if (const auto *permission = std::get_if(&event)) { + applyPermissionRequest(*permission); } else if (const auto *usage = std::get_if(&event)) { if (Message *assistant = activeAssistantMessage()) { assistant->usage = usage->usage; @@ -419,6 +785,27 @@ void Session::syncAssistantRows() void Session::endTurn() { + const QStringList unanswered(m_pendingPermissions.cbegin(), m_pendingPermissions.cend()); + for (const QString &requestId : unanswered) { + qWarning("QodeAssist: permission request %s outlived its turn and was declined", + qUtf8Printable(requestId)); + applyPermissionResolution( + PermissionResolved{.turnId = m_activeTurnId, .requestId = requestId, .cancelled = true}); + } + + if (Message *assistant = activeAssistantMessage()) { + bool interrupted = false; + for (ContentBlock &block : assistant->blocks) { + auto *tool = std::get_if(&block); + if (!tool || isTerminalToolStatus(tool->status)) + continue; + tool->status = QStringLiteral("interrupted"); + interrupted = true; + } + if (interrupted) + syncAssistantRows(); + } + m_activeTurnId.clear(); m_textSegment.clear(); m_assistantRowStart = -1; diff --git a/sources/session/Session.hpp b/sources/session/Session.hpp index 6f74937..8b73773 100644 --- a/sources/session/Session.hpp +++ b/sources/session/Session.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -38,11 +39,12 @@ public: void truncateRows(int rowIndex); void sendTurn( - const QList &userBlocks, - const std::optional &context, - const TurnOptions &options); + const QList &userBlocks, const std::optional &context); void cancel(); + void respondPermission(const QString &requestId, const QString &optionId); + bool isPermissionPending(const QString &requestId) const; + bool updateFileEditStatus( const QString &editId, const QString &status, const QString &statusMessage = {}); @@ -55,9 +57,25 @@ signals: void turnFinished(const QString &turnId); void turnFailed(const QString &error); void usageReceived(const QodeAssist::Session::Usage &usage); + void sessionInfoReceived(const QString &title); + void agentFileEditRecorded( + const QString &turnId, + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent); private: void handleEvent(const SessionEvent &event); + void applyToolCall(const ToolCallUpdated &update); + void applyAgentPlan(const PlanUpdated &plan); + bool refreshAssistantBlockRow(const ContentBlock &block, RowKind kind, const QString &rowId); + void applyPermissionRequest(const PermissionRequested &request); + void applyPermissionResolution(const PermissionResolved &resolution); + void mutatePermissionBlock( + const QString &requestId, const std::function &mutate); + std::optional permissionBlock(const QString &requestId) const; + QString autoAnswerOptionFor(const PermissionRequested &request) const; void appendMessage(const Message &message); Message *activeAssistantMessage(); void mutateAssistant(const std::function &mutate); @@ -73,6 +91,9 @@ private: QString m_activeTurnId; QString m_textSegment; int m_assistantRowStart = -1; + QSet m_pendingPermissions; + QSet m_alwaysAllowedToolKinds; + QSet m_alwaysRejectedToolKinds; }; } // namespace QodeAssist::Session diff --git a/sources/session/SessionEvent.cpp b/sources/session/SessionEvent.cpp index 996fa90..302644f 100644 --- a/sources/session/SessionEvent.cpp +++ b/sources/session/SessionEvent.cpp @@ -8,7 +8,14 @@ namespace QodeAssist::Session { QString turnIdOf(const SessionEvent &event) { - return std::visit([](const auto &alternative) { return alternative.turnId; }, event); + return std::visit( + [](const auto &alternative) -> QString { + if constexpr (requires { alternative.turnId; }) + return alternative.turnId; + else + return {}; + }, + event); } } // namespace QodeAssist::Session diff --git a/sources/session/SessionEvent.hpp b/sources/session/SessionEvent.hpp index 88b7bd4..517084a 100644 --- a/sources/session/SessionEvent.hpp +++ b/sources/session/SessionEvent.hpp @@ -10,7 +10,9 @@ #include +#include "session/AgentPlan.hpp" #include "session/Message.hpp" +#include "session/PermissionRequest.hpp" namespace QodeAssist::Session { @@ -39,25 +41,50 @@ struct ThinkingReceived bool operator==(const ThinkingReceived &other) const = default; }; -struct ToolCallStarted +struct ToolCallUpdated { QString turnId; QString toolId; QString name; + QString kind; + QString status; QJsonObject arguments; + QString result; + QJsonObject details; bool dropPrecedingText = false; + bool fromAgent = false; - bool operator==(const ToolCallStarted &other) const = default; + bool operator==(const ToolCallUpdated &other) const = default; }; -struct ToolCallCompleted +struct PlanUpdated { QString turnId; - QString toolId; - QString name; - QString result; + QList entries; - bool operator==(const ToolCallCompleted &other) const = default; + bool operator==(const PlanUpdated &other) const = default; +}; + +struct PermissionRequested +{ + QString turnId; + QString requestId; + QString toolCallId; + QString title; + QString toolKind; + QList options; + + bool operator==(const PermissionRequested &other) const = default; +}; + +struct PermissionResolved +{ + QString turnId; + QString requestId; + QString optionId; + bool cancelled = false; + + bool operator==(const PermissionResolved &other) const = default; }; struct UsageReported @@ -68,6 +95,13 @@ struct UsageReported bool operator==(const UsageReported &other) const = default; }; +struct SessionInfo +{ + QString title; + + bool operator==(const SessionInfo &other) const = default; +}; + struct TurnCompleted { QString turnId; @@ -87,9 +121,12 @@ using SessionEvent = std::variant< TurnStarted, TextDelta, ThinkingReceived, - ToolCallStarted, - ToolCallCompleted, + ToolCallUpdated, + PlanUpdated, + PermissionRequested, + PermissionResolved, UsageReported, + SessionInfo, TurnCompleted, TurnFailed>; diff --git a/sources/session/TurnContext.cpp b/sources/session/TurnContext.cpp index df45620..73a309f 100644 --- a/sources/session/TurnContext.cpp +++ b/sources/session/TurnContext.cpp @@ -10,9 +10,6 @@ QString renderSystemPrompt(const TurnContext &context) { QString prompt = context.basePrompt; - if (context.rolePrompt) - prompt += "\n\n" + *context.rolePrompt; - if (context.project.available) { prompt += QString("\n# Active project: %1").arg(context.project.displayName); prompt += QString( @@ -28,9 +25,6 @@ QString renderSystemPrompt(const TurnContext &context) "create or edit source files here): %1") .arg(*context.project.buildDirectory); } - - if (!context.projectRules.isEmpty()) - prompt += QString("\n# Project Rules\n\n") + context.projectRules; } else { prompt += QString("\n# No active project in IDE"); } @@ -44,12 +38,6 @@ QString renderSystemPrompt(const TurnContext &context) for (const InvokedSkill &skill : context.invokedSkills) prompt += QString("\n\n# Invoked Skill: %1\n\n%2").arg(skill.name, skill.body); - if (!context.linkedFilePaths.isEmpty()) { - prompt += "\n\nLinked files for reference:\n"; - for (const LinkedFile &file : context.linkedFiles) - prompt += QString("\nFile: %1\nContent:\n%2\n").arg(file.fileName, file.content); - } - return prompt; } diff --git a/sources/session/TurnContext.hpp b/sources/session/TurnContext.hpp index 3a7d287..734a366 100644 --- a/sources/session/TurnContext.hpp +++ b/sources/session/TurnContext.hpp @@ -29,25 +29,20 @@ struct InvokedSkill bool operator==(const InvokedSkill &other) const = default; }; -struct LinkedFile +struct TurnContextNeeds { - QString fileName; - QString content; + bool systemPrompt = true; - bool operator==(const LinkedFile &other) const = default; + bool operator==(const TurnContextNeeds &other) const = default; }; struct TurnContext { QString basePrompt; - std::optional rolePrompt; ProjectInfo project; - QString projectRules; QString alwaysOnSkills; QString skillsCatalog; QList invokedSkills; - QList linkedFilePaths; - QList linkedFiles; bool operator==(const TurnContext &other) const = default; }; diff --git a/sources/session/TurnContextBuilder.cpp b/sources/session/TurnContextBuilder.cpp index c02a030..a718d53 100644 --- a/sources/session/TurnContextBuilder.cpp +++ b/sources/session/TurnContextBuilder.cpp @@ -10,25 +10,18 @@ namespace QodeAssist::Session { TurnContextBuilder::TurnContextBuilder( - const IProjectContextPort &project, - const ISkillsContextPort *skills, - const ILinkedFilesPort &linkedFiles) + const IProjectContextPort &project, const ISkillsContextPort *skills) : m_project(project) , m_skills(skills) - , m_linkedFiles(linkedFiles) {} TurnContext TurnContextBuilder::build(const TurnContextRequest &request) const { TurnContext context; context.basePrompt = request.basePrompt; - context.rolePrompt = request.rolePrompt; - context.project = m_project.projectInfo(); - if (context.project.available) - context.projectRules = m_project.projectRules(); - if (m_skills) { + if (m_skills && request.needs.systemPrompt) { context.alwaysOnSkills = m_skills->alwaysOnBodies(); context.skillsCatalog = m_skills->catalogText(); @@ -48,10 +41,6 @@ TurnContext TurnContextBuilder::build(const TurnContextRequest &request) const } } - context.linkedFilePaths = request.linkedFilePaths; - if (!request.linkedFilePaths.isEmpty()) - context.linkedFiles = m_linkedFiles.readFiles(request.linkedFilePaths); - return context; } diff --git a/sources/session/TurnContextBuilder.hpp b/sources/session/TurnContextBuilder.hpp index d1c97d2..cfe6b77 100644 --- a/sources/session/TurnContextBuilder.hpp +++ b/sources/session/TurnContextBuilder.hpp @@ -4,11 +4,8 @@ #pragma once -#include #include -#include - #include "session/TurnContext.hpp" #include "session/TurnContextPorts.hpp" @@ -18,29 +15,21 @@ struct TurnContextRequest { QString message; QString basePrompt; - std::optional rolePrompt; - QList linkedFilePaths; + TurnContextNeeds needs; }; class TurnContextBuilder { public: - TurnContextBuilder( - const IProjectContextPort &project, - const ISkillsContextPort *skills, - const ILinkedFilesPort &linkedFiles); + TurnContextBuilder(const IProjectContextPort &project, const ISkillsContextPort *skills); - TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *, const ILinkedFilesPort &) - = delete; - TurnContextBuilder(const IProjectContextPort &, const ISkillsContextPort *, ILinkedFilesPort &&) - = delete; + TurnContextBuilder(IProjectContextPort &&, const ISkillsContextPort *) = delete; TurnContext build(const TurnContextRequest &request) const; private: const IProjectContextPort &m_project; const ISkillsContextPort *m_skills = nullptr; - const ILinkedFilesPort &m_linkedFiles; }; } // namespace QodeAssist::Session diff --git a/sources/session/TurnContextPorts.hpp b/sources/session/TurnContextPorts.hpp index e78ed12..2fb4dba 100644 --- a/sources/session/TurnContextPorts.hpp +++ b/sources/session/TurnContextPorts.hpp @@ -23,7 +23,6 @@ public: virtual ~IProjectContextPort() = default; virtual ProjectInfo projectInfo() const = 0; - virtual QString projectRules() const = 0; }; class ISkillsContextPort @@ -39,15 +38,4 @@ public: virtual std::optional findSkill(const QString &name) const = 0; }; -class ILinkedFilesPort -{ - Q_DISABLE_COPY_MOVE(ILinkedFilesPort) - -public: - ILinkedFilesPort() = default; - virtual ~ILinkedFilesPort() = default; - - virtual QList readFiles(const QList &paths) const = 0; -}; - } // namespace QodeAssist::Session diff --git a/sources/session/TurnLedger.cpp b/sources/session/TurnLedger.cpp new file mode 100644 index 0000000..1065290 --- /dev/null +++ b/sources/session/TurnLedger.cpp @@ -0,0 +1,103 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "session/TurnLedger.hpp" + +#include + +#include + +namespace QodeAssist::Session { + +TurnLedger::~TurnLedger() +{ + drainPermissions(); +} + +QString TurnLedger::beginTurn(const QString &turnId) +{ + m_activeTurnId = turnId; + return m_activeTurnId; +} + +QStringList TurnLedger::endTurn() +{ + m_activeTurnId.clear(); + return drainPermissions(); +} + +QString TurnLedger::activeTurnId() const +{ + return m_activeTurnId; +} + +bool TurnLedger::hasActiveTurn() const +{ + return !m_activeTurnId.isEmpty(); +} + +bool TurnLedger::isActiveTurn(const QString &turnId) const +{ + return !m_activeTurnId.isEmpty() && turnId == m_activeTurnId; +} + +QString TurnLedger::registerPermission(PermissionResolver resolve, PermissionCanceller cancel) +{ + const QString requestId + = QStringLiteral("perm-%1").arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); + m_pending.insert(requestId, PendingPermission{std::move(resolve), std::move(cancel)}); + return requestId; +} + +bool TurnLedger::resolvePermission(const QString &requestId, const QString &optionId) +{ + const auto it = m_pending.constFind(requestId); + if (it == m_pending.constEnd()) + return false; + + const PendingPermission pending = it.value(); + m_pending.erase(it); + + if (pending.resolve) + pending.resolve(optionId); + return true; +} + +bool TurnLedger::cancelPermission(const QString &requestId) +{ + const auto it = m_pending.constFind(requestId); + if (it == m_pending.constEnd()) + return false; + + const PendingPermission pending = it.value(); + m_pending.erase(it); + + if (pending.cancel) + pending.cancel(); + return true; +} + +QStringList TurnLedger::drainPermissions() +{ + const QHash pending = std::exchange(m_pending, {}); + + for (const PendingPermission &entry : pending) { + if (entry.cancel) + entry.cancel(); + } + + return pending.keys(); +} + +bool TurnLedger::hasPendingPermission(const QString &requestId) const +{ + return m_pending.contains(requestId); +} + +int TurnLedger::pendingPermissionCount() const +{ + return static_cast(m_pending.size()); +} + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnLedger.hpp b/sources/session/TurnLedger.hpp new file mode 100644 index 0000000..a6dcb03 --- /dev/null +++ b/sources/session/TurnLedger.hpp @@ -0,0 +1,50 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include + +#include + +namespace QodeAssist::Session { + +class TurnLedger +{ +public: + using PermissionResolver = std::function; + using PermissionCanceller = std::function; + + TurnLedger() = default; + ~TurnLedger(); + TurnLedger(const TurnLedger &) = delete; + TurnLedger &operator=(const TurnLedger &) = delete; + + QString beginTurn(const QString &turnId); + QStringList endTurn(); + QString activeTurnId() const; + bool hasActiveTurn() const; + bool isActiveTurn(const QString &turnId) const; + + QString registerPermission(PermissionResolver resolve, PermissionCanceller cancel); + bool resolvePermission(const QString &requestId, const QString &optionId); + bool cancelPermission(const QString &requestId); + QStringList drainPermissions(); + bool hasPendingPermission(const QString &requestId) const; + int pendingPermissionCount() const; + +private: + struct PendingPermission + { + PermissionResolver resolve; + PermissionCanceller cancel; + }; + + QString m_activeTurnId; + QHash m_pending; +}; + +} // namespace QodeAssist::Session diff --git a/sources/session/TurnRequest.hpp b/sources/session/TurnRequest.hpp index 3161a45..8829f8d 100644 --- a/sources/session/TurnRequest.hpp +++ b/sources/session/TurnRequest.hpp @@ -14,20 +14,11 @@ namespace QodeAssist::Session { -struct TurnOptions -{ - bool useTools = false; - bool useThinking = false; - - bool operator==(const TurnOptions &other) const = default; -}; - struct TurnRequest { QList userBlocks; const ConversationHistory *history = nullptr; std::optional context; - TurnOptions options; }; } // namespace QodeAssist::Session diff --git a/sources/settings/AgentRole.cpp b/sources/settings/AgentRole.cpp deleted file mode 100644 index 07f23c3..0000000 --- a/sources/settings/AgentRole.cpp +++ /dev/null @@ -1,211 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRole.hpp" - -#include - -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -QString AgentRolesManager::getConfigurationDirectory() -{ - QString path = QString("%1/qodeassist/agent_roles") - .arg(Core::ICore::userResourcePath().toFSPathString()); - QDir().mkpath(path); - return path; -} - -QList AgentRolesManager::loadAllRoles() -{ - QList roles; - QString configDir = getConfigurationDirectory(); - QDir dir(configDir); - - ensureDefaultRoles(); - - const QStringList jsonFiles = dir.entryList({"*.json"}, QDir::Files); - for (const QString &fileName : jsonFiles) { - AgentRole role = loadRoleFromFile(dir.absoluteFilePath(fileName)); - if (!role.id.isEmpty()) { - roles.append(role); - } - } - - return roles; -} - -AgentRole AgentRolesManager::loadRole(const QString &roleId) -{ - if (roleId.isEmpty()) - return {}; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); - if (!QFile::exists(filePath)) - return {}; - - return loadRoleFromFile(filePath); -} - -AgentRole AgentRolesManager::loadRoleFromFile(const QString &filePath) -{ - QFile file(filePath); - if (!file.open(QIODevice::ReadOnly)) - return {}; - - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull() || !doc.isObject()) - return {}; - - return AgentRole::fromJson(doc.object()); -} - -bool AgentRolesManager::saveRole(const AgentRole &role) -{ - if (role.id.isEmpty()) - return false; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(role.id + ".json"); - QFile file(filePath); - - if (!file.open(QIODevice::WriteOnly)) - return false; - - QJsonDocument doc(role.toJson()); - file.write(doc.toJson(QJsonDocument::Indented)); - - return true; -} - -bool AgentRolesManager::deleteRole(const QString &roleId) -{ - if (roleId.isEmpty()) - return false; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); - return QFile::remove(filePath); -} - -bool AgentRolesManager::roleExists(const QString &roleId) -{ - if (roleId.isEmpty()) - return false; - - QString filePath = QDir(getConfigurationDirectory()).absoluteFilePath(roleId + ".json"); - return QFile::exists(filePath); -} - -void AgentRolesManager::ensureDefaultRoles() -{ - QDir dir(getConfigurationDirectory()); - - if (!dir.exists("developer.json")) - saveRole(getDefaultDeveloperRole()); - - if (!dir.exists("reviewer.json")) - saveRole(getDefaultReviewerRole()); - - if (!dir.exists("researcher.json")) - saveRole(getDefaultResearcherRole()); -} - -AgentRole AgentRolesManager::getDefaultDeveloperRole() -{ - return AgentRole{ - "developer", - "Developer", - "Experienced Qt/C++ developer for implementation tasks", - "You are an experienced Qt/C++ developer working on a Qt Creator plugin.\n\n" - "Your workflow:\n" - "1. **Analyze** - understand the problem and what needs to be done\n" - "2. **Propose solution** - explain your approach in 2-3 sentences\n" - "3. **Wait for approval** - don't write code until the solution is confirmed\n" - "4. **Implement** - write clean, minimal code that solves the task\n\n" - "When analyzing:\n" - "- Ask clarifying questions if requirements are unclear\n" - "- Check existing code for similar patterns\n" - "- Consider edge cases and potential issues\n\n" - "When proposing:\n" - "- Explain what you'll change and why\n" - "- Mention files you'll modify\n" - "- Note any architectural implications\n\n" - "When implementing:\n" - "- Use C++20, Qt6, follow existing codebase style\n" - "- Write only what's needed (MVP approach)\n" - "- Include file paths and necessary changes\n" - "- Handle errors properly\n" - "- Make sure it compiles\n\n" - "Keep it practical:\n" - "- Short explanations, let code speak\n" - "- No over-engineering or unnecessary refactoring\n" - "- No TODOs, debug code, or unfinished work\n" - "- Point out non-obvious things\n\n" - "You're a pragmatic team member who thinks before coding.", - true}; -} - -AgentRole AgentRolesManager::getDefaultReviewerRole() -{ - return AgentRole{ - "reviewer", - "Code Reviewer", - "Expert C++/QML code reviewer for quality assurance", - "You are an expert C++/QML code reviewer specializing in C++20 and Qt6.\n\n" - "What you check:\n" - "- Bugs, memory leaks, undefined behavior\n" - "- C++20 compliance and Qt6 patterns\n" - "- RAII, move semantics, smart pointers\n" - "- Qt parent-child ownership and signal/slot correctness\n" - "- Thread safety and Qt concurrent usage\n" - "- const-correctness and Qt container usage\n" - "- Performance bottlenecks\n" - "- Production readiness: error handling, no debug leftovers\n\n" - "What you do:\n" - "- Point out problems with clear explanations\n" - "- Suggest specific fixes with code examples\n" - "- Remove unnecessary comments, keep essential docs only\n" - "- Flag anything that's not production-ready\n" - "- Recommend optimizations when you spot them\n\n" - "Focus on: correctness, performance, maintainability, Qt idioms.\n\n" - "Be direct and specific. Show, don't just tell.", - true}; -} - -AgentRole AgentRolesManager::getDefaultResearcherRole() -{ - return AgentRole{ - "researcher", - "Researcher", - "Research-oriented developer for exploring solutions", - "You are a research-oriented Qt/C++ developer who investigates problems and explores " - "solutions.\n\n" - "Your job is to think, not to code:\n" - "- Deep dive into the problem before suggesting anything\n" - "- Research Qt docs, patterns, and best practices\n" - "- Find multiple ways to solve it\n" - "- Compare trade-offs: performance, complexity, maintainability\n" - "- Look for relevant Qt APIs and modules\n" - "- Think about architectural consequences\n\n" - "How you work:\n" - "1. **Problem Analysis** - what exactly needs solving\n" - "2. **Research Findings** - what you learned about this problem space\n" - "3. **Solution Options** - present 2-3 approaches with honest pros/cons\n" - "4. **Recommendation** - which one fits best and why\n" - "5. **Next Steps** - what to consider before implementing\n\n" - "What you provide:\n" - "- Clear comparison of different approaches\n" - "- Code snippets as examples (not ready-to-use patches)\n" - "- Links to docs, examples, similar implementations\n" - "- Questions to clarify requirements\n" - "- Warning about potential problems\n\n" - "You DO NOT write implementation code. You explore options and let the developer choose.\n\n" - "Think like a consultant: research thoroughly, present clearly, stay objective.", - true}; -} - -} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentRole.hpp b/sources/settings/AgentRole.hpp deleted file mode 100644 index 5652b0b..0000000 --- a/sources/settings/AgentRole.hpp +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include -#include -#include - -namespace QodeAssist::Settings { - -struct AgentRole -{ - QString id; - QString name; - QString description; - QString systemPrompt; - bool isBuiltin = false; - - QJsonObject toJson() const - { - return QJsonObject{ - {"id", id}, - {"name", name}, - {"description", description}, - {"systemPrompt", systemPrompt}, - {"isBuiltin", isBuiltin}}; - } - - static AgentRole fromJson(const QJsonObject &json) - { - return AgentRole{ - json["id"].toString(), - json["name"].toString(), - json["description"].toString(), - json["systemPrompt"].toString(), - json["isBuiltin"].toBool(false)}; - } - - bool operator==(const AgentRole &other) const { return id == other.id; } -}; - -class AgentRolesManager -{ -public: - static QString getConfigurationDirectory(); - static QList loadAllRoles(); - static AgentRole loadRole(const QString &roleId); - static AgentRole loadRoleFromFile(const QString &filePath); - static bool saveRole(const AgentRole &role); - static bool deleteRole(const QString &roleId); - static bool roleExists(const QString &roleId); - static void ensureDefaultRoles(); - - static AgentRole getNoRole() - { - return AgentRole{"", "No Role", "Use base system prompt without role specialization", "", false}; - } - -private: - static AgentRole getDefaultDeveloperRole(); - static AgentRole getDefaultReviewerRole(); - static AgentRole getDefaultResearcherRole(); -}; - -} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentRoleDialog.cpp b/sources/settings/AgentRoleDialog.cpp deleted file mode 100644 index 76b4ed5..0000000 --- a/sources/settings/AgentRoleDialog.cpp +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRoleDialog.hpp" - -#include -#include -#include -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -AgentRoleDialog::AgentRoleDialog(Action action, QWidget *parent) - : QDialog{parent} - , m_action{action} -{ - auto getTitle = [](Action action) { - switch(action) - { - case Action::Add: - return tr("Add Agent Role"); - case Action::Duplicate: - return tr("Duplicate Agent Role"); - case Action::Edit: - return tr("Edit Agent Role"); - } - }; - - setWindowTitle(getTitle(action)); - setupUI(); -} - -void AgentRoleDialog::setupUI() -{ - auto *mainLayout = new QVBoxLayout(this); - auto *formLayout = new QFormLayout(); - - m_nameEdit = new QLineEdit(this); - m_nameEdit->setPlaceholderText(tr("e.g., Developer, Code Reviewer")); - formLayout->addRow(tr("Name:"), m_nameEdit); - - m_idEdit = new QLineEdit(this); - m_idEdit->setPlaceholderText(tr("e.g., developer, code_reviewer")); - formLayout->addRow(tr("ID:"), m_idEdit); - - m_descriptionEdit = new QTextEdit(this); - m_descriptionEdit->setPlaceholderText(tr("Brief description of this role...")); - m_descriptionEdit->setMaximumHeight(80); - formLayout->addRow(tr("Description:"), m_descriptionEdit); - - mainLayout->addLayout(formLayout); - - auto *promptLabel = new QLabel(tr("System Prompt:"), this); - mainLayout->addWidget(promptLabel); - - m_systemPromptEdit = new QTextEdit(this); - m_systemPromptEdit->setPlaceholderText( - tr("You are an expert in...\n\nYour role is to:\n- Task 1\n- Task 2\n- Task 3")); - mainLayout->addWidget(m_systemPromptEdit); - - m_buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); - mainLayout->addWidget(m_buttonBox); - - connect(m_buttonBox, &QDialogButtonBox::accepted, this, &AgentRoleDialog::accept); - connect(m_buttonBox, &QDialogButtonBox::rejected, this, &AgentRoleDialog::reject); - connect(m_nameEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput); - connect(m_idEdit, &QLineEdit::textChanged, this, &AgentRoleDialog::validateInput); - connect(m_systemPromptEdit, &QTextEdit::textChanged, this, &AgentRoleDialog::validateInput); - - if (m_action == Action::Edit) { - m_idEdit->setEnabled(false); - m_idEdit->setToolTip(tr("ID cannot be changed for existing roles")); - } - - setMinimumSize(600, 500); - validateInput(); -} - -void AgentRoleDialog::validateInput() -{ - bool valid = !m_nameEdit->text().trimmed().isEmpty() - && !m_idEdit->text().trimmed().isEmpty() - && !m_systemPromptEdit->toPlainText().trimmed().isEmpty(); - - m_buttonBox->button(QDialogButtonBox::Ok)->setEnabled(valid); -} - -AgentRole AgentRoleDialog::getRole() const -{ - return AgentRole{ - m_idEdit->text().trimmed(), - m_nameEdit->text().trimmed(), - m_descriptionEdit->toPlainText().trimmed(), - m_systemPromptEdit->toPlainText().trimmed(), - false}; -} - -void AgentRoleDialog::setRole(const AgentRole &role) -{ - m_idEdit->setText(role.id); - m_nameEdit->setText(role.name); - m_descriptionEdit->setPlainText(role.description); - m_systemPromptEdit->setPlainText(role.systemPrompt); -} - -} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentRoleDialog.hpp b/sources/settings/AgentRoleDialog.hpp deleted file mode 100644 index 48d5ec3..0000000 --- a/sources/settings/AgentRoleDialog.hpp +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -#include "AgentRole.hpp" - -class QLineEdit; -class QTextEdit; -class QDialogButtonBox; - -namespace QodeAssist::Settings { - -class AgentRoleDialog : public QDialog -{ - Q_OBJECT - -public: - enum class Action { - Add, - Duplicate, - Edit, - }; - - explicit AgentRoleDialog(Action action, QWidget *parent = nullptr); - explicit AgentRoleDialog(const AgentRole &role, Action action, QWidget *parent = nullptr) - : AgentRoleDialog{action, parent} - { - setRole(role); - } - - AgentRole getRole() const; - void setRole(const AgentRole &role); - -private: - void setupUI(); - void validateInput(); - - QLineEdit *m_nameEdit = nullptr; - QLineEdit *m_idEdit = nullptr; - QTextEdit *m_descriptionEdit = nullptr; - QTextEdit *m_systemPromptEdit = nullptr; - QDialogButtonBox *m_buttonBox = nullptr; - Action m_action; -}; - -} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentRolesWidget.cpp b/sources/settings/AgentRolesWidget.cpp deleted file mode 100644 index a8ac0d8..0000000 --- a/sources/settings/AgentRolesWidget.cpp +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#include "AgentRolesWidget.hpp" - -#include "AgentRole.hpp" -#include "AgentRoleDialog.hpp" -#include "SettingsTr.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace QodeAssist::Settings { - -void AgentRolesWidget::setupUI() -{ - auto *mainLayout = new QVBoxLayout(this); - - auto *headerLayout = new QHBoxLayout(); - - auto *infoLabel = new QLabel( - Tr::tr("Agent roles define different system prompts for specific tasks."), this); - infoLabel->setWordWrap(true); - headerLayout->addWidget(infoLabel, 1); - - auto *openFolderButton = new QPushButton(Tr::tr("Open Roles Folder..."), this); - connect(openFolderButton, &QPushButton::clicked, this, &AgentRolesWidget::onOpenRolesFolder); - headerLayout->addWidget(openFolderButton); - - mainLayout->addLayout(headerLayout); - - auto *contentLayout = new QHBoxLayout(); - - m_rolesList = new QListWidget(this); - m_rolesList->setSelectionMode(QAbstractItemView::SingleSelection); - connect(m_rolesList, &QListWidget::itemSelectionChanged, this, &AgentRolesWidget::updateButtons); - connect(m_rolesList, &QListWidget::itemDoubleClicked, this, &AgentRolesWidget::onEditRole); - contentLayout->addWidget(m_rolesList, 1); - - auto *buttonsLayout = new QVBoxLayout(); - - m_addButton = new QPushButton(Tr::tr("Add..."), this); - connect(m_addButton, &QPushButton::clicked, this, &AgentRolesWidget::onAddRole); - buttonsLayout->addWidget(m_addButton); - - m_editButton = new QPushButton(Tr::tr("Edit..."), this); - connect(m_editButton, &QPushButton::clicked, this, &AgentRolesWidget::onEditRole); - buttonsLayout->addWidget(m_editButton); - - m_duplicateButton = new QPushButton(Tr::tr("Duplicate..."), this); - connect(m_duplicateButton, &QPushButton::clicked, this, &AgentRolesWidget::onDuplicateRole); - buttonsLayout->addWidget(m_duplicateButton); - - m_deleteButton = new QPushButton(Tr::tr("Delete"), this); - connect(m_deleteButton, &QPushButton::clicked, this, &AgentRolesWidget::onDeleteRole); - buttonsLayout->addWidget(m_deleteButton); - - buttonsLayout->addStretch(); - - contentLayout->addLayout(buttonsLayout); - mainLayout->addLayout(contentLayout); - - updateButtons(); -} - -void AgentRolesWidget::loadRoles() -{ - m_rolesList->clear(); - - const QList roles = AgentRolesManager::loadAllRoles(); - for (const AgentRole &role : roles) { - auto *item = new QListWidgetItem(role.name, m_rolesList); - item->setData(Qt::UserRole, role.id); - - QString tooltip = role.description; - if (role.isBuiltin) { - tooltip += "\n\n" + Tr::tr("(Built-in role)"); - item->setForeground(Qt::darkGray); - } - item->setToolTip(tooltip); - } -} - -void AgentRolesWidget::updateButtons() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - bool hasSelection = selectedItem != nullptr; - bool isBuiltin = false; - - if (hasSelection) { - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - isBuiltin = role.isBuiltin; - } - - m_editButton->setEnabled(hasSelection); - m_duplicateButton->setEnabled(hasSelection); - m_deleteButton->setEnabled(hasSelection && !isBuiltin); -} - -void AgentRolesWidget::onAddRole() -{ - AgentRoleDialog dialog{AgentRoleDialog::Action::Add, this}; - if (dialog.exec() != QDialog::Accepted) - return; - - AgentRole newRole = dialog.getRole(); - - if (AgentRolesManager::roleExists(newRole.id)) { - QMessageBox::warning( - this, - Tr::tr("Role Already Exists"), - Tr::tr("A role with ID '%1' already exists. Please use a different ID.") - .arg(newRole.id)); - return; - } - - if (AgentRolesManager::saveRole(newRole)) { - loadRoles(); - } else { - QMessageBox::critical( - this, Tr::tr("Error"), Tr::tr("Failed to save role '%1'.").arg(newRole.name)); - } -} - -void AgentRolesWidget::onEditRole() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - if (!selectedItem) - return; - - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - - if (role.isBuiltin) { - QMessageBox::information( - this, - Tr::tr("Cannot Edit Built-in Role"), - Tr::tr( - "Built-in roles cannot be edited. You can duplicate this role and modify the copy.")); - return; - } - - AgentRoleDialog dialog{role, AgentRoleDialog::Action::Edit, this}; - if (dialog.exec() != QDialog::Accepted) - return; - - AgentRole updatedRole = dialog.getRole(); - - if (AgentRolesManager::saveRole(updatedRole)) { - loadRoles(); - } else { - QMessageBox::critical( - this, Tr::tr("Error"), Tr::tr("Failed to update role '%1'.").arg(updatedRole.name)); - } -} - -void AgentRolesWidget::onDuplicateRole() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - if (!selectedItem) - return; - - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - - role.name += " (Copy)"; - role.id += "_copy"; - role.isBuiltin = false; - - int counter = 1; - QString baseId = role.id; - while (AgentRolesManager::roleExists(role.id)) { - role.id = baseId + QString::number(counter++); - } - - AgentRoleDialog dialog{role, AgentRoleDialog::Action::Duplicate, this}; - if (dialog.exec() != QDialog::Accepted) - return; - - AgentRole newRole = dialog.getRole(); - - if (AgentRolesManager::roleExists(newRole.id)) { - QMessageBox::warning( - this, - Tr::tr("Role Already Exists"), - Tr::tr("A role with ID '%1' already exists. Please use a different ID.") - .arg(newRole.id)); - return; - } - - if (AgentRolesManager::saveRole(newRole)) { - loadRoles(); - } else { - QMessageBox::critical(this, Tr::tr("Error"), Tr::tr("Failed to duplicate role.")); - } -} - -void AgentRolesWidget::onDeleteRole() -{ - QListWidgetItem *selectedItem = m_rolesList->currentItem(); - if (!selectedItem) - return; - - QString roleId = selectedItem->data(Qt::UserRole).toString(); - AgentRole role = AgentRolesManager::loadRole(roleId); - - if (role.isBuiltin) { - QMessageBox::information( - this, Tr::tr("Cannot Delete Built-in Role"), Tr::tr("Built-in roles cannot be deleted.")); - return; - } - - QMessageBox::StandardButton reply = QMessageBox::question( - this, - Tr::tr("Delete Role"), - Tr::tr("Are you sure you want to delete the role '%1'?").arg(role.name), - QMessageBox::Yes | QMessageBox::No); - - if (reply == QMessageBox::Yes) { - if (AgentRolesManager::deleteRole(roleId)) { - loadRoles(); - } else { - QMessageBox::critical( - this, Tr::tr("Error"), Tr::tr("Failed to delete role '%1'.").arg(role.name)); - } - } -} - -void AgentRolesWidget::onOpenRolesFolder() -{ - QDesktopServices::openUrl(QUrl::fromLocalFile(AgentRolesManager::getConfigurationDirectory())); -} - -} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentRolesWidget.hpp b/sources/settings/AgentRolesWidget.hpp deleted file mode 100644 index 6378c2d..0000000 --- a/sources/settings/AgentRolesWidget.hpp +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (C) 2024-2026 Petr Mironychev -// SPDX-License-Identifier: GPL-3.0-or-later -// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE - -#pragma once - -#include - -class QListWidget; -class QPushButton; - -namespace QodeAssist::Settings { - -class AgentRolesWidget : public Core::IOptionsPageWidget -{ - Q_OBJECT - -public: - explicit AgentRolesWidget() - { - setupUI(); - loadRoles(); - } - -private: - void setupUI(); - void loadRoles(); - void updateButtons(); - - void onAddRole(); - void onEditRole(); - void onDuplicateRole(); - void onDeleteRole(); - void onOpenRolesFolder(); - - QListWidget *m_rolesList = nullptr; - QPushButton *m_addButton = nullptr; - QPushButton *m_editButton = nullptr; - QPushButton *m_duplicateButton = nullptr; - QPushButton *m_deleteButton = nullptr; -}; - -} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentsSettings.cpp b/sources/settings/AgentsSettings.cpp new file mode 100644 index 0000000..bf970d5 --- /dev/null +++ b/sources/settings/AgentsSettings.cpp @@ -0,0 +1,56 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentsSettings.hpp" + +#include + +#include "SettingsConstants.hpp" +#include "SettingsTr.hpp" + +namespace QodeAssist::Settings { + +AgentsSettings &agentsSettings() +{ + static AgentsSettings settings; + return settings; +} + +AgentsSettings::AgentsSettings() +{ + setAutoApply(false); + + setDisplayName(Tr::tr("Agents")); + + agentExtraPaths.setSettingsKey(Constants::AGENT_EXTRA_PATHS); + agentExtraPaths.setLabelText(Tr::tr("Extra PATH for launching agents:")); + agentExtraPaths.setDisplayStyle(Utils::StringAspect::LineEditDisplay); + agentExtraPaths.setToolTip( + Tr::tr( + "Directories to prepend to PATH when launching ACP agents, and to search for " + "the agent's executable. Useful when Qt Creator is started from the dock and " + "doesn't see Homebrew, nvm, uv, etc. Separate multiple entries with '%1'.") + .arg(QDir::listSeparator())); +#ifdef Q_OS_MACOS + agentExtraPaths.setDefaultValue(QStringLiteral("/opt/homebrew/bin:/usr/local/bin")); +#else + agentExtraPaths.setDefaultValue(QString{}); +#endif + + agentForwardedVariables.setSettingsKey(Constants::AGENT_FORWARDED_VARIABLES); + agentForwardedVariables.setLabelText(Tr::tr("Forward these variables to agents:")); + agentForwardedVariables.setDisplayStyle(Utils::StringAspect::LineEditDisplay); + agentForwardedVariables.setToolTip( + Tr::tr( + "Names of environment variables (not their values) passed on to ACP agents, " + "separated by spaces or commas. A variable already present in Qt Creator's own " + "environment is used as is; on macOS and Linux the rest are read once from a login " + "shell, so a token exported in your shell profile reaches agents even when Qt " + "Creator was started from the dock.")); + agentForwardedVariables.setDefaultValue(QStringLiteral("CLAUDE_CODE_OAUTH_TOKEN")); + + readSettings(); +} + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentsSettings.hpp b/sources/settings/AgentsSettings.hpp new file mode 100644 index 0000000..ec6a863 --- /dev/null +++ b/sources/settings/AgentsSettings.hpp @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +namespace QodeAssist::Settings { + +class AgentsSettings : public Utils::AspectContainer +{ +public: + AgentsSettings(); + + Utils::StringAspect agentExtraPaths{this}; + Utils::StringAspect agentForwardedVariables{this}; +}; + +AgentsSettings &agentsSettings(); + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentsWidget.cpp b/sources/settings/AgentsWidget.cpp new file mode 100644 index 0000000..ea4851d --- /dev/null +++ b/sources/settings/AgentsWidget.cpp @@ -0,0 +1,348 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "AgentsWidget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "AgentsSettings.hpp" +#include "SettingsConstants.hpp" +#include "SettingsTr.hpp" +#include "acp/AgentCatalogStore.hpp" +#include "acp/AgentTester.hpp" + +namespace QodeAssist::Settings { + +namespace { + +constexpr int agentIdRole = Qt::UserRole; + +QString distributionSummary(const Acp::AgentDefinition &agent) +{ + const QString kind = Acp::agentDistributionName(agent.distribution.kind); + switch (agent.distribution.kind) { + case Acp::AgentDistributionKind::Npx: + case Acp::AgentDistributionKind::Uvx: + return QStringLiteral("%1 (%2)").arg(kind, agent.distribution.package); + case Acp::AgentDistributionKind::Command: + return QStringLiteral("%1 (%2)").arg(kind, agent.distribution.command); + case Acp::AgentDistributionKind::Binary: + case Acp::AgentDistributionKind::Unknown: + return kind; + } + return kind; +} + +QString linkRow(const QString &label, const QString &url) +{ + if (url.isEmpty()) + return {}; + return QStringLiteral("%1 %2
") + .arg(label.toHtmlEscaped(), url.toHtmlEscaped()); +} + +QString unavailableGuidance(const Acp::AgentDefinition &agent) +{ + if (agent.distribution.kind == Acp::AgentDistributionKind::Binary) { + QString text = Tr::tr( + "This agent ships as a platform binary. QodeAssist does not download binaries yet: " + "install it manually, then add a JSON file to the agents folder with a " + "\"command\" distribution pointing at the executable."); + + if (const Acp::AgentBinaryTarget *target = Acp::binaryTargetForCurrentPlatform(agent)) { + text += QStringLiteral("

") + + Tr::tr("Download for %1:").arg(target->platform.toHtmlEscaped()) + + QStringLiteral(" %1").arg(target->archive.toHtmlEscaped()); + } + return text; + } + + return Tr::tr("This agent has no distribution QodeAssist can launch."); +} + +} // namespace + +AgentsWidget::AgentsWidget(Acp::AgentCatalogStore *store) + : m_store(store) + , m_tester(new Acp::AgentTester(this)) +{ + setupUI(); + + connect(m_store, &Acp::AgentCatalogStore::catalogChanged, this, &AgentsWidget::populateList); + connect( + m_store, + &Acp::AgentCatalogStore::refreshFinished, + this, + [this](bool ok, const QString &error) { + showStatus( + ok ? Tr::tr("Agent list refreshed from the registry.") + : Tr::tr("Registry refresh failed: %1").arg(error)); + updateButtons(); + }); + + connect(m_tester, &Acp::AgentTester::finished, this, [this](bool ok, const QString &report) { + showStatus(ok ? Tr::tr("Agent responded.") : Tr::tr("Agent test failed.")); + m_details->append( + QStringLiteral("
%1
%2
") + .arg(ok ? Tr::tr("Test result") : Tr::tr("Test failed"), report.toHtmlEscaped())); + updateButtons(); + }); + + populateList(); +} + +void AgentsWidget::apply() +{ + auto &settings = agentsSettings(); + settings.agentExtraPaths.setValue(m_extraPathsEdit->text()); + settings.agentForwardedVariables.setValue(m_forwardedVariablesEdit->text()); + settings.writeSettings(); +} + +QLineEdit *AgentsWidget::addSettingRow(QVBoxLayout *layout, Utils::StringAspect &aspect) +{ + auto *row = new QHBoxLayout(); + auto *label = new QLabel(aspect.labelText(), this); + label->setToolTip(aspect.toolTip()); + + auto *edit = new QLineEdit(aspect.volatileValue(), this); + edit->setToolTip(aspect.toolTip()); + connect(edit, &QLineEdit::textChanged, &aspect, [&aspect](const QString &text) { + aspect.setVolatileValue(text); + }); + + row->addWidget(label); + row->addWidget(edit, 1); + layout->addLayout(row); + + return edit; +} + +void AgentsWidget::setupUI() +{ + auto *mainLayout = new QVBoxLayout(this); + + auto *headerLayout = new QHBoxLayout(); + auto *info = new QLabel( + Tr::tr( + "ACP agents from your own JSON files, the agent registry, and the bundled " + "snapshot. Definitions with the same id override each other in that order."), + this); + info->setWordWrap(true); + headerLayout->addWidget(info, 1); + + auto *openFolderButton = new QPushButton(Tr::tr("Open Agents Folder..."), this); + connect(openFolderButton, &QPushButton::clicked, this, &AgentsWidget::onOpenAgentsFolder); + headerLayout->addWidget(openFolderButton); + mainLayout->addLayout(headerLayout); + + m_extraPathsEdit = addSettingRow(mainLayout, agentsSettings().agentExtraPaths); + m_forwardedVariablesEdit = addSettingRow(mainLayout, agentsSettings().agentForwardedVariables); + + auto *contentLayout = new QHBoxLayout(); + + m_agentsList = new QListWidget(this); + m_agentsList->setSelectionMode(QAbstractItemView::SingleSelection); + connect(m_agentsList, &QListWidget::itemSelectionChanged, this, &AgentsWidget::showSelectedAgent); + contentLayout->addWidget(m_agentsList, 1); + + auto *buttonsLayout = new QVBoxLayout(); + + m_testButton = new QPushButton(Tr::tr("Test"), this); + m_testButton->setToolTip( + Tr::tr("Start the agent, run the ACP handshake and report its capabilities.")); + connect(m_testButton, &QPushButton::clicked, this, &AgentsWidget::onTest); + buttonsLayout->addWidget(m_testButton); + + m_reloadButton = new QPushButton(Tr::tr("Reload"), this); + m_reloadButton->setToolTip(Tr::tr("Re-read the agent JSON files and the cached registry.")); + connect(m_reloadButton, &QPushButton::clicked, this, [this]() { + m_store->reload(); + showStatus(Tr::tr("Agent list reloaded from disk.")); + }); + buttonsLayout->addWidget(m_reloadButton); + + m_refreshButton = new QPushButton(Tr::tr("Refresh from Registry"), this); + m_refreshButton->setToolTip(Tr::tr("Download the agent registry and update the local cache.")); + connect(m_refreshButton, &QPushButton::clicked, this, &AgentsWidget::onRefresh); + buttonsLayout->addWidget(m_refreshButton); + + buttonsLayout->addStretch(); + contentLayout->addLayout(buttonsLayout); + mainLayout->addLayout(contentLayout, 1); + + m_details = new QTextBrowser(this); + m_details->setOpenExternalLinks(true); + m_details->setMinimumHeight(160); + mainLayout->addWidget(m_details, 1); + + m_status = new QLabel(this); + m_status->setWordWrap(true); + mainLayout->addWidget(m_status); +} + +void AgentsWidget::populateList() +{ + const QString selectedId = m_agentsList->currentItem() + ? m_agentsList->currentItem()->data(agentIdRole).toString() + : QString(); + + m_agentsList->clear(); + + const QList agents = m_store->catalog().agents(); + for (const Acp::AgentDefinition &agent : agents) { + const QString label = agent.version.isEmpty() + ? agent.name + : QStringLiteral("%1 %2").arg(agent.name, agent.version); + auto *item = new QListWidgetItem(label, m_agentsList); + item->setData(agentIdRole, agent.id); + + if (agent.isLaunchable()) { + item->setToolTip( + QStringLiteral("%1 — %2") + .arg(distributionSummary(agent), Acp::agentSourceName(agent.source))); + } else { + item->setForeground(palette().brush(QPalette::Disabled, QPalette::Text)); + item->setToolTip(unavailableGuidance(agent)); + } + + if (agent.id == selectedId) + m_agentsList->setCurrentItem(item); + } + + showStatus( + m_store->hasCachedRegistry() + ? QString() + : Tr::tr( + "Showing the bundled agent snapshot — press Refresh from Registry for the " + "published list.")); + showSelectedAgent(); +} + +void AgentsWidget::showStatus(const QString &message) +{ + const QStringList warnings = m_store->warnings(); + if (warnings.isEmpty()) { + m_status->setText(message); + return; + } + + const QString ignored = Tr::tr("Ignored entries: %1").arg(warnings.join(QStringLiteral("; "))); + m_status->setText(message.isEmpty() ? ignored : QStringLiteral("%1 %2").arg(message, ignored)); +} + +void AgentsWidget::showSelectedAgent() +{ + updateButtons(); + + const auto agent = selectedAgent(); + if (!agent) { + m_details->clear(); + return; + } + + QString html = QStringLiteral("

%1

").arg(agent->name.toHtmlEscaped()); + if (!agent->description.isEmpty()) + html += QStringLiteral("

%1

").arg(agent->description.toHtmlEscaped()); + + html += QStringLiteral("

"); + html += QStringLiteral("%1 %2
").arg(Tr::tr("Id:"), agent->id.toHtmlEscaped()); + html += QStringLiteral("%1 %2
") + .arg(Tr::tr("Distribution:"), distributionSummary(*agent).toHtmlEscaped()); + html += QStringLiteral("%1 %2
") + .arg(Tr::tr("Defined by:"), agent->origin.toHtmlEscaped()); + if (!agent->license.isEmpty()) + html += QStringLiteral("%1 %2
") + .arg(Tr::tr("License:"), agent->license.toHtmlEscaped()); + if (!agent->authors.isEmpty()) + html += QStringLiteral("%1 %2
") + .arg( + Tr::tr("Authors:"), + agent->authors.join(QStringLiteral(", ")).toHtmlEscaped()); + html += linkRow(Tr::tr("Repository:"), agent->repository); + html += linkRow(Tr::tr("Website:"), agent->website); + html += QStringLiteral("

"); + + if (!agent->isLaunchable()) + html += QStringLiteral("

%1

").arg(unavailableGuidance(*agent)); + + m_details->setHtml(html); +} + +void AgentsWidget::updateButtons() +{ + const auto agent = selectedAgent(); + const bool testing = m_tester->isRunning(); + m_testButton->setText(testing ? Tr::tr("Cancel Test") : Tr::tr("Test")); + m_testButton->setEnabled(testing || (agent && agent->isLaunchable())); + m_refreshButton->setEnabled(!m_store->isRefreshing()); + m_refreshButton->setText( + m_store->isRefreshing() ? Tr::tr("Refreshing...") : Tr::tr("Refresh from Registry")); +} + +void AgentsWidget::onTest() +{ + if (m_tester->isRunning()) { + m_tester->cancel(); + return; + } + + const auto agent = selectedAgent(); + if (!agent) + return; + + showStatus(Tr::tr("Starting %1...").arg(agent->name)); + m_tester->start(*agent, testWorkingDirectory()); + updateButtons(); +} + +void AgentsWidget::onRefresh() +{ + showStatus(Tr::tr("Downloading the agent registry...")); + m_store->refreshFromRegistry(); + updateButtons(); +} + +void AgentsWidget::onOpenAgentsFolder() +{ + QDesktopServices::openUrl(QUrl::fromLocalFile(Acp::AgentCatalogStore::userAgentsDirectory())); +} + +std::optional AgentsWidget::selectedAgent() const +{ + const QListWidgetItem *item = m_agentsList->currentItem(); + if (!item) + return std::nullopt; + return m_store->catalog().agent(item->data(agentIdRole).toString()); +} + +QString AgentsWidget::testWorkingDirectory() +{ + if (const ProjectExplorer::Project *project = ProjectExplorer::ProjectManager::startupProject()) + return project->projectDirectory().path(); + return QDir::homePath(); +} + +AgentsSettingsPage::AgentsSettingsPage(Acp::AgentCatalogStore *store) +{ + setId(Constants::QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID); + setDisplayName(Tr::tr("Agents")); + setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); + setWidgetCreator([store]() { return new AgentsWidget(store); }); +} + +} // namespace QodeAssist::Settings diff --git a/sources/settings/AgentsWidget.hpp b/sources/settings/AgentsWidget.hpp new file mode 100644 index 0000000..083a687 --- /dev/null +++ b/sources/settings/AgentsWidget.hpp @@ -0,0 +1,74 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include +#include + +#include "acp/AgentDefinition.hpp" + +QT_BEGIN_NAMESPACE +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; +class QTextBrowser; +class QVBoxLayout; +QT_END_NAMESPACE + +namespace QodeAssist::Acp { +class AgentCatalogStore; +class AgentTester; +} // namespace QodeAssist::Acp + +namespace QodeAssist::Settings { + +class AgentsWidget : public Core::IOptionsPageWidget +{ + Q_OBJECT + +public: + explicit AgentsWidget(Acp::AgentCatalogStore *store); + + void apply() override; + +private: + void setupUI(); + void populateList(); + void updateButtons(); + void showSelectedAgent(); + void showStatus(const QString &message); + + void onTest(); + void onRefresh(); + void onOpenAgentsFolder(); + + std::optional selectedAgent() const; + static QString testWorkingDirectory(); + + Acp::AgentCatalogStore *m_store = nullptr; + Acp::AgentTester *m_tester = nullptr; + + QLineEdit *addSettingRow(QVBoxLayout *layout, Utils::StringAspect &aspect); + + QLineEdit *m_extraPathsEdit = nullptr; + QLineEdit *m_forwardedVariablesEdit = nullptr; + QListWidget *m_agentsList = nullptr; + QTextBrowser *m_details = nullptr; + QLabel *m_status = nullptr; + QPushButton *m_testButton = nullptr; + QPushButton *m_reloadButton = nullptr; + QPushButton *m_refreshButton = nullptr; +}; + +class AgentsSettingsPage : public Core::IOptionsPage +{ +public: + explicit AgentsSettingsPage(Acp::AgentCatalogStore *store); +}; + +} // namespace QodeAssist::Settings diff --git a/sources/settings/CMakeLists.txt b/sources/settings/CMakeLists.txt index 4f8160b..3978a2a 100644 --- a/sources/settings/CMakeLists.txt +++ b/sources/settings/CMakeLists.txt @@ -18,9 +18,6 @@ add_library(QodeAssistSettings STATIC ProviderNameMigration.hpp PluginUpdater.hpp PluginUpdater.cpp UpdateDialog.hpp UpdateDialog.cpp - AgentRole.hpp AgentRole.cpp - AgentRoleDialog.hpp AgentRoleDialog.cpp - AgentRolesWidget.hpp AgentRolesWidget.cpp ) target_link_libraries(QodeAssistSettings diff --git a/sources/settings/ChatAssistantSettings.cpp b/sources/settings/ChatAssistantSettings.cpp index ccbc58c..c2b8689 100644 --- a/sources/settings/ChatAssistantSettings.cpp +++ b/sources/settings/ChatAssistantSettings.cpp @@ -14,7 +14,6 @@ #include "SettingsConstants.hpp" #include "SettingsTr.hpp" #include "SettingsUtils.hpp" -#include "AgentRolesWidget.hpp" namespace QodeAssist::Settings { @@ -30,10 +29,6 @@ ChatAssistantSettings::ChatAssistantSettings() setDisplayName(Tr::tr("Chat Assistant")); - linkOpenFiles.setSettingsKey(Constants::CA_LINK_OPEN_FILES); - linkOpenFiles.setLabelText(Tr::tr("Sync open files with assistant by default")); - linkOpenFiles.setDefaultValue(false); - autosave.setSettingsKey(Constants::CA_AUTOSAVE); autosave.setDefaultValue(true); autosave.setLabelText(Tr::tr("Enable autosave when message received")); @@ -252,9 +247,6 @@ ChatAssistantSettings::ChatAssistantSettings() chatRenderer.setDefaultValue("rhi"); #endif - lastUsedRoleId.setSettingsKey(Constants::CA_LAST_USED_ROLE); - lastUsedRoleId.setDefaultValue(""); - resetToDefaults.m_buttonText = TrConstants::RESET_TO_DEFAULTS; readSettings(); @@ -297,7 +289,6 @@ ChatAssistantSettings::ChatAssistantSettings() Group{ title(Tr::tr("Chat Settings")), Column{ - linkOpenFiles, autosave, Row{autoCompress, autoCompressThreshold, Stretch{1}}}}, Space{8}, @@ -371,7 +362,6 @@ void ChatAssistantSettings::resetSettingsToDefaults() resetAspect(thinkingBudgetTokens); resetAspect(thinkingMaxTokens); resetAspect(openAIResponsesReasoningEffort); - resetAspect(linkOpenFiles); resetAspect(enableChatTools); resetAspect(textFontFamily); resetAspect(codeFontFamily); @@ -397,18 +387,4 @@ public: const ChatAssistantSettingsPage chatAssistantSettingsPage; -class AgentRolesSettingsPage : public Core::IOptionsPage -{ -public: - AgentRolesSettingsPage() - { - setId("QodeAssist.AgentRoles"); - setDisplayName(Tr::tr("Agent Roles")); - setCategory(Constants::QODE_ASSIST_GENERAL_OPTIONS_CATEGORY); - setWidgetCreator([]() { return new AgentRolesWidget(); }); - } -}; - -const AgentRolesSettingsPage agentRolesSettingsPage; - } // namespace QodeAssist::Settings diff --git a/sources/settings/ChatAssistantSettings.hpp b/sources/settings/ChatAssistantSettings.hpp index 4e95899..3f9d624 100644 --- a/sources/settings/ChatAssistantSettings.hpp +++ b/sources/settings/ChatAssistantSettings.hpp @@ -6,7 +6,6 @@ #include -#include "AgentRole.hpp" #include "ButtonAspect.hpp" namespace QodeAssist::Settings { @@ -19,7 +18,6 @@ public: ButtonAspect resetToDefaults{this}; // Chat settings - Utils::BoolAspect linkOpenFiles{this}; Utils::BoolAspect autosave{this}; Utils::BoolAspect enableChatInBottomToolBar{this}; Utils::BoolAspect enableChatInNavigationPanel{this}; @@ -69,8 +67,6 @@ public: Utils::SelectionAspect chatRenderer{this}; - Utils::StringAspect lastUsedRoleId{this}; - private: void setupConnections(); void resetSettingsToDefaults(); diff --git a/sources/settings/CodeCompletionSettings.cpp b/sources/settings/CodeCompletionSettings.cpp index 4344fa8..45025c5 100644 --- a/sources/settings/CodeCompletionSettings.cpp +++ b/sources/settings/CodeCompletionSettings.cpp @@ -303,14 +303,6 @@ CodeCompletionSettings::CodeCompletionSettings() useOpenFilesContext.setLabelText(Tr::tr("Include context from open files")); useOpenFilesContext.setDefaultValue(false); - useProjectChangesCache.setSettingsKey(Constants::CC_USE_PROJECT_CHANGES_CACHE); - useProjectChangesCache.setDefaultValue(true); - useProjectChangesCache.setLabelText(Tr::tr("Max Changes Cache Size:")); - - maxChangesCacheSize.setSettingsKey(Constants::CC_MAX_CHANGES_CACHE_SIZE); - maxChangesCacheSize.setRange(2, 1000); - maxChangesCacheSize.setDefaultValue(10); - // Ollama Settings ollamaLivetime.setSettingsKey(Constants::CC_OLLAMA_LIVETIME); ollamaLivetime.setToolTip( @@ -389,8 +381,7 @@ CodeCompletionSettings::CodeCompletionSettings() systemPromptForNonFimModels, userMessageTemplateForCC, customLanguages, - }}, - Row{useProjectChangesCache, maxChangesCacheSize, Stretch{1}}}; + }}}; auto generalSettings = Column{ autoCompletion, @@ -486,8 +477,6 @@ void CodeCompletionSettings::resetSettingsToDefaults() resetAspect(readStringsAfterCursor); resetAspect(useSystemPrompt); resetAspect(systemPrompt); - resetAspect(useProjectChangesCache); - resetAspect(maxChangesCacheSize); resetAspect(ollamaLivetime); resetAspect(contextWindow); resetAspect(openAIResponsesReasoningEffort); diff --git a/sources/settings/CodeCompletionSettings.hpp b/sources/settings/CodeCompletionSettings.hpp index 131aca9..dfa1d2d 100644 --- a/sources/settings/CodeCompletionSettings.hpp +++ b/sources/settings/CodeCompletionSettings.hpp @@ -68,8 +68,6 @@ public: Utils::BoolAspect useUserMessageTemplateForCC{this}; Utils::StringAspect systemPromptForNonFimModels{this}; Utils::StringAspect userMessageTemplateForCC{this}; - Utils::BoolAspect useProjectChangesCache{this}; - Utils::IntegerAspect maxChangesCacheSize{this}; // Ollama Settings Utils::StringAspect ollamaLivetime{this}; diff --git a/sources/settings/SettingsConstants.hpp b/sources/settings/SettingsConstants.hpp index 4770877..04a2f8c 100644 --- a/sources/settings/SettingsConstants.hpp +++ b/sources/settings/SettingsConstants.hpp @@ -82,7 +82,6 @@ const char CC_MODEL_OUTPUT_HANDLER[] = "QodeAssist.ccModelOutputHandler"; const char CA_AUTO_APPLY_FILE_EDITS[] = "QodeAssist.caAutoApplyFileEdits"; const char CA_AUTO_COMPRESS[] = "QodeAssist.caAutoCompress"; const char CA_AUTO_COMPRESS_THRESHOLD[] = "QodeAssist.caAutoCompressThreshold"; -const char CA_LINK_OPEN_FILES[] = "QodeAssist.caLinkOpenFiles"; const char CA_AUTOSAVE[] = "QodeAssist.caAutosave"; const char CC_CUSTOM_LANGUAGES[] = "QodeAssist.ccCustomLanguages"; @@ -122,6 +121,10 @@ const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort"; const char MCP_ENABLE_CLIENTS[] = "QodeAssist.mcpEnableClients"; const char MCP_CLIENT_EXTRA_PATHS[] = "QodeAssist.mcpClientExtraPaths"; +// ACP agent settings +const char AGENT_EXTRA_PATHS[] = "QodeAssist.agentExtraPaths"; +const char AGENT_FORWARDED_VARIABLES[] = "QodeAssist.agentForwardedVariables"; + const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions"; const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; const char QODE_ASSIST_CODE_COMPLETION_SETTINGS_PAGE_ID[] @@ -133,6 +136,7 @@ const char QODE_ASSIST_QUICK_REFACTOR_SETTINGS_PAGE_ID[] const char QODE_ASSIST_TOOLS_SETTINGS_PAGE_ID[] = "QodeAssist.5ToolsSettingsPageId"; const char QODE_ASSIST_MCP_SETTINGS_PAGE_ID[] = "QodeAssist.6McpSettingsPageId"; const char QODE_ASSIST_SKILLS_SETTINGS_PAGE_ID[] = "QodeAssist.8SkillsSettingsPageId"; +const char QODE_ASSIST_AGENTS_SETTINGS_PAGE_ID[] = "QodeAssist.9AgentsSettingsPageId"; const char QODE_ASSIST_GENERAL_OPTIONS_CATEGORY[] = "QodeAssist.Category"; const char QODE_ASSIST_GENERAL_OPTIONS_DISPLAY_CATEGORY[] = "QodeAssist"; @@ -176,8 +180,6 @@ const char CC_SYSTEM_PROMPT[] = "QodeAssist.ccSystemPrompt"; const char CC_SYSTEM_PROMPT_FOR_NON_FIM[] = "QodeAssist.ccSystemPromptForNonFim"; const char CC_USE_USER_TEMPLATE[] = "QodeAssist.ccUseUserTemplate"; const char CC_USER_TEMPLATE[] = "QodeAssist.ccUserTemplate"; -const char CC_USE_PROJECT_CHANGES_CACHE[] = "QodeAssist.ccUseProjectChangesCache"; -const char CC_MAX_CHANGES_CACHE_SIZE[] = "QodeAssist.ccMaxChangesCacheSize"; const char CA_USE_SYSTEM_PROMPT[] = "QodeAssist.useChatSystemPrompt"; const char CA_SYSTEM_PROMPT[] = "QodeAssist.chatSystemPrompt"; @@ -224,7 +226,6 @@ const char CA_CODE_FONT_SIZE[] = "QodeAssist.caCodeFontSize"; const char CA_TEXT_FORMAT[] = "QodeAssist.caTextFormat"; const char CA_CHAT_RENDERER[] = "QodeAssist.caChatRenderer"; -const char CA_LAST_USED_ROLE[] = "QodeAssist.caLastUsedRole"; // quick refactor preset prompt settings const char QR_TEMPERATURE[] = "QodeAssist.qrTemperature"; diff --git a/sources/tools/EditFileTool.cpp b/sources/tools/EditFileTool.cpp index 771fe14..4078b03 100644 --- a/sources/tools/EditFileTool.cpp +++ b/sources/tools/EditFileTool.cpp @@ -6,15 +6,15 @@ #include -#include +#include #include #include +#include #include #include #include #include #include -#include #include #include #include @@ -181,7 +181,7 @@ QFuture EditFileTool::executeAsync(const QJsonObject &input .arg(newContent.left(200))); } - Context::ChangesManager::instance().addFileEdit( + Context::FileEditManager::instance().addFileEdit( editId, filePath, oldContent, @@ -191,13 +191,13 @@ QFuture EditFileTool::executeAsync(const QJsonObject &input requestId ); - auto edit = Context::ChangesManager::instance().getFileEdit(editId); + auto edit = Context::FileEditManager::instance().getFileEdit(editId); QString status = "pending"; - if (edit.status == Context::ChangesManager::Applied) { + if (edit.status == Context::FileEditManager::Applied) { status = "applied"; - } else if (edit.status == Context::ChangesManager::Rejected) { + } else if (edit.status == Context::FileEditManager::Rejected) { status = "rejected"; - } else if (edit.status == Context::ChangesManager::Archived) { + } else if (edit.status == Context::FileEditManager::Archived) { status = "archived"; } @@ -214,9 +214,7 @@ QFuture EditFileTool::executeAsync(const QJsonObject &input LOG_MESSAGE(QString("File edit created: %1 (ID: %2, Status: %3, Deferred: %4)") .arg(filePath, editId, status, requestId.isEmpty() ? QString("no") : QString("yes"))); - QString resultStr = "QODEASSIST_FILE_EDIT:" - + QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Compact)); - return LLMQore::ToolResult::text(resultStr); + return LLMQore::ToolResult::text(Session::encodeFileEditPayload(result)); }); } diff --git a/sources/tools/EditorStateTools.cpp b/sources/tools/EditorStateTools.cpp new file mode 100644 index 0000000..1e5bcaf --- /dev/null +++ b/sources/tools/EditorStateTools.cpp @@ -0,0 +1,266 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#include "EditorStateTools.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace QodeAssist::Tools { + +namespace { + +constexpr int maxUnsavedTextChars = 128 * 1024; +constexpr int maxTotalUnsavedChars = 1024 * 1024; +constexpr int maxSelectionChars = 64 * 1024; +constexpr int maxOpenEditors = 200; + +QFuture readyResult(LLMQore::ToolResult result) +{ + QPromise promise; + promise.start(); + promise.addResult(std::move(result)); + promise.finish(); + return promise.future(); +} + +QFuture readyJson(const QJsonObject &payload) +{ + return readyResult( + LLMQore::ToolResult::text( + QString::fromUtf8(QJsonDocument(payload).toJson(QJsonDocument::Compact)))); +} + +QString clampText(const QString &text, int limit) +{ + return text.size() <= limit ? text : text.first(limit) + QStringLiteral("\n[truncated]"); +} + +} // namespace + +ListOpenEditorsTool::ListOpenEditorsTool(QObject *parent) + : LLMQore::BaseTool(parent) +{} + +QString ListOpenEditorsTool::id() const +{ + return QStringLiteral("list_open_editors"); +} + +QString ListOpenEditorsTool::displayName() const +{ + return tr("List Open Editors"); +} + +QString ListOpenEditorsTool::description() const +{ + return QStringLiteral( + "List the files currently open in Qt Creator, marking which one is active and which have " + "unsaved changes. Set include_unsaved_text to get the in-editor text of modified files, " + "which differs from what is on disk. Read-only."); +} + +QJsonObject ListOpenEditorsTool::parametersSchema() const +{ + return QJsonObject{ + {"type", "object"}, + {"properties", + QJsonObject{ + {"include_unsaved_text", + QJsonObject{ + {"type", "boolean"}, + {"description", "Include the editor text of files with unsaved changes."}}}}}, + {"required", QJsonArray{}}}; +} + +void ListOpenEditorsTool::setIgnorePredicate(std::function predicate) +{ + m_ignorePredicate = std::move(predicate); +} + +QFuture ListOpenEditorsTool::executeAsync(const QJsonObject &input) +{ + Q_ASSERT_X( + thread() == qApp->thread(), + "ListOpenEditorsTool", + "Qt Creator's document model may only be read on the GUI thread"); + + const bool includeUnsaved = input.value("include_unsaved_text").toBool(false); + + const Core::IDocument *currentDocument = Core::EditorManager::currentDocument(); + const QList documents = Core::DocumentModel::openedDocuments(); + + QJsonArray editors; + int skipped = 0; + int unsavedBudget = maxTotalUnsavedChars; + + for (const Core::IDocument *document : documents) { + const auto *textDocument = qobject_cast(document); + if (!textDocument) + continue; + + const QString filePath = textDocument->filePath().toUrlishString(); + if (m_ignorePredicate && m_ignorePredicate(filePath)) { + ++skipped; + continue; + } + + if (editors.size() >= maxOpenEditors) { + ++skipped; + continue; + } + + QJsonObject entry{ + {"path", filePath}, + {"modified", textDocument->isModified()}, + {"active", document == currentDocument}}; + + if (includeUnsaved && textDocument->isModified()) { + if (unsavedBudget <= 0) { + entry["unsaved_text_omitted"] = true; + } else { + const QString text + = clampText(textDocument->plainText(), qMin(unsavedBudget, maxUnsavedTextChars)); + unsavedBudget -= text.size(); + entry["unsaved_text"] = text; + } + } + + editors.append(entry); + } + + QJsonObject payload{{"editors", editors}}; + if (skipped > 0) + payload["omitted"] = skipped; + + return readyJson(payload); +} + +GetEditorSelectionTool::GetEditorSelectionTool(QObject *parent) + : LLMQore::BaseTool(parent) +{} + +QString GetEditorSelectionTool::id() const +{ + return QStringLiteral("get_editor_selection"); +} + +QString GetEditorSelectionTool::displayName() const +{ + return tr("Get Editor Selection"); +} + +QString GetEditorSelectionTool::description() const +{ + return QStringLiteral( + "Return what the user is currently looking at in Qt Creator: the active file, the cursor " + "position, and the selected text if there is a selection. Read-only."); +} + +QJsonObject GetEditorSelectionTool::parametersSchema() const +{ + return QJsonObject{{"type", "object"}, {"properties", QJsonObject{}}, {"required", QJsonArray{}}}; +} + +QFuture GetEditorSelectionTool::executeAsync(const QJsonObject &input) +{ + Q_UNUSED(input) + + auto *editor = TextEditor::BaseTextEditor::currentTextEditor(); + if (!editor) + return readyJson(QJsonObject{{"active", false}}); + + const QTextCursor cursor = editor->editorWidget()->textCursor(); + + QJsonObject payload{ + {"active", true}, + {"path", editor->document()->filePath().toUrlishString()}, + {"line", cursor.blockNumber() + 1}, + {"column", cursor.positionInBlock() + 1}, + {"has_selection", cursor.hasSelection()}}; + + if (cursor.hasSelection()) { + QString selected = cursor.selectedText(); + selected.replace(QChar(0x2029), QLatin1Char('\n')); + payload["selected_text"] = clampText(selected, maxSelectionChars); + QTextCursor start = cursor; + start.setPosition(cursor.selectionStart()); + QTextCursor end = cursor; + end.setPosition(cursor.selectionEnd()); + payload["selection_start_line"] = start.blockNumber() + 1; + payload["selection_end_line"] = end.blockNumber() + 1; + } + + return readyJson(payload); +} + +GetProjectModelTool::GetProjectModelTool(QObject *parent) + : LLMQore::BaseTool(parent) +{} + +QString GetProjectModelTool::id() const +{ + return QStringLiteral("get_project_model"); +} + +QString GetProjectModelTool::displayName() const +{ + return tr("Get Project Model"); +} + +QString GetProjectModelTool::description() const +{ + return QStringLiteral( + "Return how Qt Creator has this project configured: project name and file, the active " + "kit, build configuration and build directory. This is Qt Creator's own model, not " + "something that can be read off disk. Read-only."); +} + +QJsonObject GetProjectModelTool::parametersSchema() const +{ + return QJsonObject{{"type", "object"}, {"properties", QJsonObject{}}, {"required", QJsonArray{}}}; +} + +QFuture GetProjectModelTool::executeAsync(const QJsonObject &input) +{ + Q_UNUSED(input) + + auto *project = ProjectExplorer::ProjectManager::startupProject(); + if (!project) + return readyJson(QJsonObject{{"open", false}}); + + QJsonObject payload{ + {"open", true}, + {"name", project->displayName()}, + {"project_file", project->projectFilePath().toUrlishString()}, + {"root", project->projectDirectory().toUrlishString()}}; + + if (auto *target = project->activeTarget()) { + if (auto *kit = target->kit()) + payload["kit"] = kit->displayName(); + + if (auto *buildConfiguration = target->activeBuildConfiguration()) { + payload["build_configuration"] = buildConfiguration->displayName(); + payload["build_directory"] = buildConfiguration->buildDirectory().toUrlishString(); + } + } + + return readyJson(payload); +} + +} // namespace QodeAssist::Tools diff --git a/sources/tools/EditorStateTools.hpp b/sources/tools/EditorStateTools.hpp new file mode 100644 index 0000000..d10e7a4 --- /dev/null +++ b/sources/tools/EditorStateTools.hpp @@ -0,0 +1,66 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include + +#include + +namespace QodeAssist::Tools { + +class ListOpenEditorsTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + explicit ListOpenEditorsTool(QObject *parent = nullptr); + + void setIgnorePredicate(std::function predicate); + + QString id() const override; + QString displayName() const override; + QString description() const override; + QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } + + QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; + +private: + std::function m_ignorePredicate; +}; + +class GetEditorSelectionTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + explicit GetEditorSelectionTool(QObject *parent = nullptr); + + QString id() const override; + QString displayName() const override; + QString description() const override; + QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } + + QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; +}; + +class GetProjectModelTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + explicit GetProjectModelTool(QObject *parent = nullptr); + + QString id() const override; + QString displayName() const override; + QString description() const override; + QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } + + QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; +}; + +} // namespace QodeAssist::Tools diff --git a/sources/tools/FindFileTool.hpp b/sources/tools/FindFileTool.hpp index ea4739a..aa05afb 100644 --- a/sources/tools/FindFileTool.hpp +++ b/sources/tools/FindFileTool.hpp @@ -23,6 +23,7 @@ public: QString displayName() const override; QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input) override; private: diff --git a/sources/tools/GetIssuesListTool.hpp b/sources/tools/GetIssuesListTool.hpp index 5204f27..aae8ec9 100644 --- a/sources/tools/GetIssuesListTool.hpp +++ b/sources/tools/GetIssuesListTool.hpp @@ -42,6 +42,7 @@ public: QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; }; diff --git a/sources/tools/ListProjectFilesTool.hpp b/sources/tools/ListProjectFilesTool.hpp index dbcbd46..7d52626 100644 --- a/sources/tools/ListProjectFilesTool.hpp +++ b/sources/tools/ListProjectFilesTool.hpp @@ -21,6 +21,7 @@ public: QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; private: diff --git a/sources/tools/ProjectSearchTool.hpp b/sources/tools/ProjectSearchTool.hpp index 8bf656a..0c98566 100644 --- a/sources/tools/ProjectSearchTool.hpp +++ b/sources/tools/ProjectSearchTool.hpp @@ -23,6 +23,7 @@ public: QString displayName() const override; QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input) override; private: diff --git a/sources/tools/ReadFileTool.hpp b/sources/tools/ReadFileTool.hpp index 8413c47..79eef19 100644 --- a/sources/tools/ReadFileTool.hpp +++ b/sources/tools/ReadFileTool.hpp @@ -22,6 +22,7 @@ public: QString displayName() const override; QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input) override; }; diff --git a/sources/tools/ReadOriginalHistoryTool.cpp b/sources/tools/ReadOriginalHistoryTool.cpp index 8fec7a9..0099700 100644 --- a/sources/tools/ReadOriginalHistoryTool.cpp +++ b/sources/tools/ReadOriginalHistoryTool.cpp @@ -30,10 +30,16 @@ QString roleName(Session::RowKind kind) return QStringLiteral("assistant"); case Session::RowKind::Tool: return QStringLiteral("tool"); + case Session::RowKind::AgentTool: + return QStringLiteral("agent_tool"); case Session::RowKind::FileEdit: return QStringLiteral("file_edit"); case Session::RowKind::Thinking: return QStringLiteral("thinking"); + case Session::RowKind::Permission: + return QStringLiteral("permission"); + case Session::RowKind::Plan: + return QStringLiteral("plan"); } return QStringLiteral("unknown"); } diff --git a/sources/tools/ReadOriginalHistoryTool.hpp b/sources/tools/ReadOriginalHistoryTool.hpp index a83b5c0..a638c6e 100644 --- a/sources/tools/ReadOriginalHistoryTool.hpp +++ b/sources/tools/ReadOriginalHistoryTool.hpp @@ -23,6 +23,7 @@ public: QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; void setCurrentSessionId(const QString &sessionId); diff --git a/sources/tools/SkillTool.hpp b/sources/tools/SkillTool.hpp index b11b898..e92976c 100644 --- a/sources/tools/SkillTool.hpp +++ b/sources/tools/SkillTool.hpp @@ -27,6 +27,7 @@ public: QString displayName() const override; QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input) override; private: diff --git a/sources/tools/TodoTool.hpp b/sources/tools/TodoTool.hpp index 1673d86..8f68a4e 100644 --- a/sources/tools/TodoTool.hpp +++ b/sources/tools/TodoTool.hpp @@ -31,6 +31,7 @@ public: QString description() const override; QJsonObject parametersSchema() const override; + ::LLMQore::ToolSafety safety() const override { return ::LLMQore::ToolSafety::ReadOnly; } QFuture executeAsync(const QJsonObject &input = QJsonObject()) override; void setCurrentSessionId(const QString &sessionId); diff --git a/tests/FakeAcpAgent.hpp b/tests/FakeAcpAgent.hpp new file mode 100644 index 0000000..74772a2 --- /dev/null +++ b/tests/FakeAcpAgent.hpp @@ -0,0 +1,324 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace QodeAssist { + +class FakeAcpAgent : public LLMQore::Rpc::Transport +{ + Q_OBJECT + +public: + explicit FakeAcpAgent(QObject *parent = nullptr) + : LLMQore::Rpc::Transport(parent) + {} + + void start() override + { + m_open = true; + setState(State::Connected); + } + + void stop() override + { + if (!m_open) + return; + m_open = false; + setState(State::Disconnected); + emit closed(); + } + + bool isOpen() const override { return m_open; } + + void send(const QJsonObject &message) override + { + const QString method = message.value("method").toString(); + + if (method.isEmpty() && message.contains("id")) { + m_permissionOutcomes.append( + message.value("result").toObject().value("outcome").toObject()); + if (!m_hangOnPrompt) + finishPrompt(); + return; + } + + m_methods.append(method); + + if (method == QLatin1String("initialize")) { + m_clientInfo = message.value("params").toObject().value("clientInfo").toObject(); + reply(message, initializeResult()); + } else if (method == QLatin1String("session/new")) { + m_newSessionCwd = message.value("params").toObject().value("cwd").toString(); + m_offeredMcpServers = message.value("params").toObject().value("mcpServers").toArray(); + if (m_hangOnNewSession) + return; + if (m_requireAuthentication && !m_authenticated) { + replyError(message, -32000, QStringLiteral("Authentication required")); + } else { + if (!m_commandsBeforeSessionReply.isEmpty()) { + postUpdate( + QJsonObject{ + {"sessionUpdate", QStringLiteral("available_commands_update")}, + {"availableCommands", m_commandsBeforeSessionReply}}); + } + reply(message, QJsonObject{{"sessionId", m_sessionId}}); + } + } else if (method == QLatin1String("session/load")) { + m_loadedSessionId = message.value("params").toObject().value("sessionId").toString(); + if (m_loadSessionError.isEmpty()) + reply(message, QJsonObject{{"sessionId", m_loadedSessionId}}); + else + replyError(message, -32603, m_loadSessionError); + } else if (method == QLatin1String("authenticate")) { + m_authenticated = true; + m_authMethodUsed = message.value("params").toObject().value("methodId").toString(); + reply(message, QJsonObject{}); + } else if (method == QLatin1String("session/prompt")) { + m_prompts.append(message.value("params").toObject().value("prompt").toArray()); + respondToPrompt(message); + } else if (method == QLatin1String("session/cancel")) { + m_cancelled = true; + } + } + + void setRequireAuthentication(bool require) { m_requireAuthentication = require; } + void setSupportsLoadSession(bool supports) { m_loadSession = supports; } + void setSupportsEmbeddedContext(bool supports) { m_embeddedContext = supports; } + void setSupportsImages(bool supports) { m_images = supports; } + void setPromptFailure(const QString &error) { m_promptError = error; } + void setHangOnPrompt(bool hang) { m_hangOnPrompt = hang; } + void setReplyChunks(const QStringList &chunks) { m_chunks = chunks; } + void setThoughtChunks(const QStringList &chunks) { m_thoughts = chunks; } + + void setPermissionRequest(const QJsonObject &toolCall, const QJsonArray &options) + { + m_permissionToolCall = toolCall; + m_permissionOptions = options; + } + + void setFinishPromptWithoutWaiting(bool finish) { m_finishPromptWithoutWaiting = finish; } + + void setToolCallUpdates(const QList &updates) { m_toolCalls = updates; } + void setPlanUpdates(const QList &plans) { m_plans = plans; } + void setAvailableCommandUpdates(const QList &updates) + { + m_availableCommandUpdates = updates; + } + void setCommandsBeforeSessionReply(const QJsonArray &commands) + { + m_commandsBeforeSessionReply = commands; + } + void setPromptUsage(const QJsonObject &usage) { m_promptUsage = usage; } + void setContextGauge(const QJsonObject &gauge) { m_contextGauge = gauge; } + void setSuggestedTitle(const QString &title) { m_suggestedTitle = title; } + void setLoadSessionError(const QString &error) { m_loadSessionError = error; } + void setHangOnNewSession(bool hang) { m_hangOnNewSession = hang; } + void setSupportsHttpMcp(bool supports) { m_httpMcp = supports; } + + QJsonArray offeredMcpServers() const { return m_offeredMcpServers; } + + QString loadedSessionId() const { return m_loadedSessionId; } + + QList permissionOutcomes() const { return m_permissionOutcomes; } + + QStringList methods() const { return m_methods; } + QString newSessionCwd() const { return m_newSessionCwd; } + QString authMethodUsed() const { return m_authMethodUsed; } + QJsonObject clientInfo() const { return m_clientInfo; } + QList prompts() const { return m_prompts; } + bool wasCancelled() const { return m_cancelled; } + +private: + QJsonObject initializeResult() const + { + QJsonObject capabilities{ + {"loadSession", m_loadSession}, + {"promptCapabilities", + QJsonObject{{"image", m_images}, {"embeddedContext", m_embeddedContext}}}, + {"mcpCapabilities", QJsonObject{{"http", m_httpMcp}, {"sse", false}}}}; + QJsonObject result{{"protocolVersion", 1}, {"agentCapabilities", capabilities}}; + result.insert( + "agentInfo", + QJsonObject{{"name", QStringLiteral("Fake")}, {"version", QStringLiteral("1.0")}}); + if (m_requireAuthentication) { + result.insert( + "authMethods", + QJsonArray{ + QJsonObject{{"id", QStringLiteral("login")}, {"name", QStringLiteral("Login")}}}); + } + return result; + } + + void respondToPrompt(const QJsonObject &request) + { + for (const QString &chunk : m_thoughts) + notifyUpdate(QStringLiteral("agent_thought_chunk"), chunk); + for (const QString &chunk : m_chunks) + notifyUpdate(QStringLiteral("agent_message_chunk"), chunk); + + for (int i = 0; i < m_toolCalls.size(); ++i) { + QJsonObject update = m_toolCalls.at(i); + update.insert( + "sessionUpdate", + i == 0 ? QStringLiteral("tool_call") : QStringLiteral("tool_call_update")); + postUpdate(update); + } + + for (const QJsonArray &entries : m_plans) + postUpdate(QJsonObject{{"sessionUpdate", QStringLiteral("plan")}, {"entries", entries}}); + + for (const QJsonArray &commands : m_availableCommandUpdates) { + postUpdate( + QJsonObject{ + {"sessionUpdate", QStringLiteral("available_commands_update")}, + {"availableCommands", commands}}); + } + + if (!m_contextGauge.isEmpty()) { + QJsonObject update = m_contextGauge; + update.insert("sessionUpdate", QStringLiteral("usage_update")); + postUpdate(update); + } + + m_activePrompt = request; + + if (!m_permissionToolCall.isEmpty()) { + askPermission(); + if (!m_finishPromptWithoutWaiting) + return; + } + + if (m_hangOnPrompt) + return; + + finishPrompt(); + } + + void askPermission() + { + post( + QJsonObject{ + {"jsonrpc", QStringLiteral("2.0")}, + {"id", ++m_outgoingId}, + {"method", QStringLiteral("session/request_permission")}, + {"params", + QJsonObject{ + {"sessionId", m_sessionId}, + {"toolCall", m_permissionToolCall}, + {"options", m_permissionOptions}}}}); + } + + void finishPrompt() + { + if (m_activePrompt.isEmpty()) + return; + + const QJsonObject request = m_activePrompt; + m_activePrompt = QJsonObject(); + + if (!m_promptError.isEmpty()) { + replyError(request, -32603, m_promptError); + return; + } + + QJsonObject result{{"stopReason", QStringLiteral("end_turn")}}; + if (!m_promptUsage.isEmpty()) + result.insert("usage", m_promptUsage); + reply(request, result); + + if (!m_suggestedTitle.isEmpty()) { + postUpdate( + QJsonObject{ + {"sessionUpdate", QStringLiteral("session_info_update")}, + {"title", m_suggestedTitle}}); + } + } + + void postUpdate(const QJsonObject &update) + { + post( + QJsonObject{ + {"jsonrpc", QStringLiteral("2.0")}, + {"method", QStringLiteral("session/update")}, + {"params", QJsonObject{{"sessionId", m_sessionId}, {"update", update}}}}); + } + + void notifyUpdate(const QString &kind, const QString &text) + { + postUpdate( + QJsonObject{ + {"sessionUpdate", kind}, + {"content", QJsonObject{{"type", QStringLiteral("text")}, {"text", text}}}}); + } + + void reply(const QJsonObject &request, const QJsonObject &result) + { + post( + QJsonObject{ + {"jsonrpc", QStringLiteral("2.0")}, + {"id", request.value("id")}, + {"result", result}}); + } + + void replyError(const QJsonObject &request, int code, const QString &message) + { + post( + QJsonObject{ + {"jsonrpc", QStringLiteral("2.0")}, + {"id", request.value("id")}, + {"error", QJsonObject{{"code", code}, {"message", message}}}}); + } + + void post(const QJsonObject &message) + { + QTimer::singleShot(0, this, [this, message]() { emit messageReceived(message); }); + } + + bool m_open = false; + bool m_hangOnPrompt = false; + bool m_loadSession = false; + bool m_embeddedContext = false; + bool m_images = false; + bool m_requireAuthentication = false; + bool m_authenticated = false; + bool m_cancelled = false; + bool m_finishPromptWithoutWaiting = false; + bool m_hangOnNewSession = false; + bool m_httpMcp = false; + QJsonArray m_offeredMcpServers; + QString m_sessionId = QStringLiteral("fake-session"); + QString m_promptError; + QString m_newSessionCwd; + QString m_authMethodUsed; + QJsonObject m_clientInfo; + int m_outgoingId = 1000; + QStringList m_chunks; + QStringList m_thoughts; + QStringList m_methods; + QList m_prompts; + QJsonObject m_activePrompt; + QJsonObject m_permissionToolCall; + QJsonArray m_permissionOptions; + QList m_permissionOutcomes; + QList m_toolCalls; + QList m_plans; + QList m_availableCommandUpdates; + QJsonArray m_commandsBeforeSessionReply; + QJsonObject m_promptUsage; + QJsonObject m_contextGauge; + QString m_suggestedTitle; + QString m_loadSessionError; + QString m_loadedSessionId; +}; + +} // namespace QodeAssist diff --git a/tests/FakeLlmProvider.hpp b/tests/FakeLlmProvider.hpp new file mode 100644 index 0000000..c791875 --- /dev/null +++ b/tests/FakeLlmProvider.hpp @@ -0,0 +1,184 @@ +// Copyright (C) 2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later +// Additional attribution terms under GPLv3 §7(b) apply — see LICENSE + +#pragma once + +#include +#include + +#include +#include +#include + +#include "providers/Provider.hpp" +#include "templates/IPromptProvider.hpp" +#include "templates/PromptTemplate.hpp" + +namespace QodeAssist { + +class FakeChatClient : public ::LLMQore::BaseClient +{ + Q_OBJECT + +public: + using BaseClient::BaseClient; + + ::LLMQore::RequestID sendMessage( + const QJsonObject &payload, + const QString &endpoint, + ::LLMQore::RequestMode mode) override + { + Q_UNUSED(endpoint) + Q_UNUSED(mode) + lastPayload = payload; + return QStringLiteral("fake-req-%1").arg(++requestCounter); + } + + ::LLMQore::RequestID ask(const QString &prompt, ::LLMQore::RequestMode mode) override + { + Q_UNUSED(prompt) + Q_UNUSED(mode) + return {}; + } + + QFuture> listModels(const QString &endpoint) override + { + Q_UNUSED(endpoint) + return QtFuture::makeReadyValueFuture(QList{}); + } + + QJsonObject lastPayload; + int requestCounter = 0; + +protected: + ::LLMQore::ToolSchemaFormat toolSchemaFormat() const override + { + return ::LLMQore::ToolSchemaFormat::OpenAI; + } + + void processData(const ::LLMQore::RequestID &, const QByteArray &) override {} + void processBufferedResponse(const ::LLMQore::RequestID &, const QByteArray &) override {} + + QNetworkRequest prepareNetworkRequest(const QUrl &url) const override + { + return QNetworkRequest(url); + } + + ::LLMQore::BaseMessage *messageForRequest(const ::LLMQore::RequestID &) const override + { + return nullptr; + } + + void cleanupDerivedData(const ::LLMQore::RequestID &) override {} + + QJsonObject buildContinuationPayload( + const QJsonObject &, + ::LLMQore::BaseMessage *, + const QHash &) override + { + return {}; + } +}; + +class FakeMutatingTool : public ::LLMQore::BaseTool +{ + Q_OBJECT + +public: + using BaseTool::BaseTool; + + QString id() const override { return QStringLiteral("fake_write"); } + QString displayName() const override { return QStringLiteral("Fake Write"); } + QString description() const override { return QStringLiteral("Writes something"); } + QJsonObject parametersSchema() const override { return {}; } + + QFuture<::LLMQore::ToolResult> executeAsync(const QJsonObject &input) override + { + Q_UNUSED(input) + ++executions; + return QtFuture::makeReadyValueFuture(::LLMQore::ToolResult::text(QStringLiteral("done"))); + } + + int executions = 0; +}; + +class FakeChatTemplate : public Templates::PromptTemplate +{ +public: + Templates::TemplateType type() const override { return Templates::TemplateType::Chat; } + QString name() const override { return QStringLiteral("FakeChatTemplate"); } + QStringList stopWords() const override { return {}; } + void prepareRequest(QJsonObject &, const LLMCore::ContextData &) const override {} + QString description() const override { return {}; } + bool isSupportProvider(Providers::ProviderID) const override { return true; } + bool supportsToolHistory() const override { return true; } +}; + +class FakeChatPromptProvider : public Templates::IPromptProvider +{ +public: + Templates::PromptTemplate *getTemplateByName(const QString &) const override + { + return &m_template; + } + + QStringList templatesNames() const override { return {m_template.name()}; } + + QStringList getTemplatesForProvider(Providers::ProviderID) const override + { + return {m_template.name()}; + } + +private: + mutable FakeChatTemplate m_template; +}; + +class FakeLlmProvider : public Providers::Provider +{ + Q_OBJECT + +public: + explicit FakeLlmProvider(QObject *parent = nullptr) + : Providers::Provider(parent) + , m_client(new FakeChatClient(this)) + {} + + QString name() const override { return QStringLiteral("FakeLLM"); } + QString url() const override { return {}; } + + void prepareRequest( + QJsonObject &, + Templates::PromptTemplate *, + LLMCore::ContextData context, + LLMCore::RequestType, + bool, + bool) override + { + lastContext = context; + } + + LLMCore::ContextData lastContext; + + QFuture> getInstalledModels(const QString &) override + { + return QtFuture::makeReadyValueFuture(QList{}); + } + + Providers::ProviderID providerID() const override { return Providers::ProviderID::Any; } + + Providers::ProviderCapabilities capabilities() const override + { + return Providers::ProviderCapability::Tools; + } + + ::LLMQore::BaseClient *client() const override { return m_client; } + QString apiKey() const override { return {}; } + + FakeChatClient *fakeClient() const { return m_client; } + +private: + FakeChatClient *m_client = nullptr; +}; + +} // namespace QodeAssist diff --git a/tests/QodeAssistTest.cpp b/tests/QodeAssistTest.cpp index 8b02c24..cfbc62e 100644 --- a/tests/QodeAssistTest.cpp +++ b/tests/QodeAssistTest.cpp @@ -5,20 +5,63 @@ #include "QodeAssistTest.hpp" -#include "ChatView/ChatHistoryBridge.hpp" #include "ChatView/ChatModel.hpp" +#include "ChatView/ChatFileStore.hpp" +#include "FakeAcpAgent.hpp" +#include "FakeLlmProvider.hpp" +#include "ChatView/ChatConfigurationController.hpp" +#include "ChatView/ConversationCoordinator.hpp" +#include "ChatView/FileMentionItem.hpp" +#include "ChatView/LlmChatBackend.hpp" +#include "settings/ChatAssistantSettings.hpp" +#include "session/BlockCodec.hpp" +#include "session/FileEditPayload.hpp" +#include "session/TurnLedger.hpp" +#include "acp/AcpChatBackend.hpp" +#include "acp/AgentBinding.hpp" +#include "acp/AgentKnowledgeService.hpp" +#include "mcp/AgentKnowledgeServer.hpp" +#include "tools/BuildProjectTool.hpp" +#include "tools/CreateNewFileTool.hpp" +#include "tools/EditFileTool.hpp" +#include "tools/ExecuteTerminalCommandTool.hpp" +#include "tools/EditorStateTools.hpp" +#include "tools/GetIssuesListTool.hpp" +#include "tools/ReadFileTool.hpp" + +#include +#include "acp/AgentCatalog.hpp" +#include "acp/AgentCatalogStore.hpp" +#include "acp/AgentLaunch.hpp" +#include "acp/AgentRegistryParser.hpp" +#include "acp/AgentSpawn.hpp" + +#include #include "completion/CodeHandler.hpp" #include "completion/LLMSuggestion.hpp" +#include "context/FileEditManager.hpp" #include "llmcore/ContextData.hpp" #include "providers/ClaudeCacheControl.hpp" +#include "session/FencedText.hpp" #include "session/HistoryProjection.hpp" #include "session/HistorySerializer.hpp" #include "session/Session.hpp" #include "session/TurnContextBuilder.hpp" +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include #include +#include namespace QodeAssist { @@ -81,7 +124,6 @@ public: void sendTurn(const Session::TurnRequest &request) override { ++m_turnCount; - m_lastOptions = request.options; m_lastUserBlocks = request.userBlocks; m_historySizeAtSend = request.history ? request.history->size() : -1; m_lastContext = request.context; @@ -89,28 +131,101 @@ public: void cancel() override { ++m_cancelCount; } + bool respondPermission(const QString &requestId, const QString &optionId) override + { + m_permissionAnswers.append({requestId, optionId}); + if (!m_acceptPermissionAnswers) + return false; + if (m_autoResolvePermissions) + emit sessionEvent(Session::PermissionResolved{m_turnId, requestId, optionId, false}); + return true; + } + void script(const QList &events) { - for (const Session::SessionEvent &scripted : events) + for (const Session::SessionEvent &scripted : events) { + if (const auto *started = std::get_if(&scripted)) + m_turnId = started->turnId; emit sessionEvent(scripted); + } } + void setAutoResolvePermissions(bool automatic) { m_autoResolvePermissions = automatic; } + void setAcceptPermissionAnswers(bool accept) { m_acceptPermissionAnswers = accept; } + + QList> permissionAnswers() const { return m_permissionAnswers; } + int turnCount() const { return m_turnCount; } int cancelCount() const { return m_cancelCount; } qsizetype historySizeAtSend() const { return m_historySizeAtSend; } const QList &lastUserBlocks() const { return m_lastUserBlocks; } const std::optional &lastContext() const { return m_lastContext; } - Session::TurnOptions lastOptions() const { return m_lastOptions; } private: int m_turnCount = 0; int m_cancelCount = 0; + bool m_autoResolvePermissions = true; + bool m_acceptPermissionAnswers = true; qsizetype m_historySizeAtSend = -1; + QString m_turnId; + QList> m_permissionAnswers; QList m_lastUserBlocks; std::optional m_lastContext; - Session::TurnOptions m_lastOptions; }; +QList editPermissionOptions() +{ + return { + Session::PermissionOption{"opt-once", "Yes", "allow_once"}, + Session::PermissionOption{"opt-always", "Yes, and don't ask again", "allow_always"}, + Session::PermissionOption{"opt-no", "No", "reject_once"}}; +} + +Session::PermissionRequested editPermissionRequest( + const QString &turnId, const QString &requestId, const QString &toolKind = QStringLiteral("edit")) +{ + return Session::PermissionRequested{ + .turnId = turnId, + .requestId = requestId, + .toolCallId = "tool-" + requestId, + .title = "Edit main.cpp", + .toolKind = toolKind, + .options = editPermissionOptions()}; +} + +Session::PermissionBlock permissionRowAt(const Session::Session &session, int index) +{ + return Session::decodePermissionBlock(session.rows().at(index).content) + .value_or(Session::PermissionBlock{}); +} + +Session::ToolCallUpdated llmToolStarted( + const QString &turnId, + const QString &toolId, + const QString &name, + const QJsonObject &arguments = {}, + bool dropPrecedingText = false) +{ + return Session::ToolCallUpdated{ + .turnId = turnId, + .toolId = toolId, + .name = name, + .status = "in_progress", + .arguments = arguments, + .dropPrecedingText = dropPrecedingText}; +} + +Session::ToolCallUpdated llmToolCompleted( + const QString &turnId, const QString &toolId, const QString &name, const QString &result) +{ + return Session::ToolCallUpdated{ + .turnId = turnId, + .toolId = toolId, + .name = name, + .status = "completed", + .result = result}; +} + QList scriptedTurn(const QString &turnId) { return { @@ -118,8 +233,8 @@ QList scriptedTurn(const QString &turnId) Session::ThinkingReceived{turnId, "weighing options", "sig-1", false}, Session::TextDelta{turnId, "let me "}, Session::TextDelta{turnId, "check"}, - Session::ToolCallStarted{turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, false}, - Session::ToolCallCompleted{turnId, "t1", "read_file", "int main() {}"}, + llmToolStarted(turnId, "t1", "read_file", QJsonObject{{"path", "main.cpp"}}), + llmToolCompleted(turnId, "t1", "read_file", "int main() {}"), Session::TextDelta{turnId, "here is the answer"}, Session::UsageReported{turnId, Session::Usage{120, 40, 80, 12}}, Session::TurnCompleted{turnId}}; @@ -128,17 +243,14 @@ QList scriptedTurn(const QString &turnId) class FakeProjectContext : public Session::IProjectContextPort { public: - FakeProjectContext(Session::ProjectInfo info, QString rules) + explicit FakeProjectContext(Session::ProjectInfo info) : m_info(std::move(info)) - , m_rules(std::move(rules)) {} Session::ProjectInfo projectInfo() const override { return m_info; } - QString projectRules() const override { return m_rules; } private: Session::ProjectInfo m_info; - QString m_rules; }; class FakeSkillsContext : public Session::ISkillsContextPort @@ -168,25 +280,18 @@ private: QList m_skills; }; -class FakeLinkedFiles : public Session::ILinkedFilesPort +void attachModelToSession(Session::Session &session, Chat::ChatModel &model) { -public: - explicit FakeLinkedFiles(QList files) - : m_files(std::move(files)) - {} - - QList readFiles(const QList &paths) const override - { - m_requestedPaths = paths; - return m_files; - } - - QList requestedPaths() const { return m_requestedPaths; } - -private: - QList m_files; - mutable QList m_requestedPaths; -}; + QObject::connect( + &session, &Session::Session::rowsReset, &model, &Chat::ChatModel::resetMessages); + QObject::connect( + &session, &Session::Session::rowsAppended, &model, &Chat::ChatModel::appendMessages); + QObject::connect( + &session, &Session::Session::rowUpdated, &model, &Chat::ChatModel::updateMessage); + QObject::connect( + &session, &Session::Session::rowsRemoved, &model, &Chat::ChatModel::removeMessages); + model.resetMessages(session.rows()); +} Session::ProjectInfo sampleProject() { @@ -198,6 +303,56 @@ Session::ProjectInfo sampleProject() return info; } +Session::ConversationHistory historyWithEveryRowKind() +{ + Session::Message system; + system.role = Session::MessageRole::System; + system.id = "s1"; + system.blocks = {Session::TextBlock{"system"}}; + + Session::Message user; + user.role = Session::MessageRole::User; + user.id = "u1"; + user.blocks = {Session::TextBlock{"question"}}; + + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "a1"; + assistant.blocks + = {Session::TextBlock{"answer"}, + Session::ThinkingBlock{"thinking", {}, false}, + Session::ToolCallBlock{"call-1", "read_file", {}, "int main() {}", {}, {}, {}, false}, + Session::ToolCallBlock{ + "call-2", "Edit main.cpp", {}, "done", "edit", "completed", {}, true}, + Session::FileEditBlock{"edit-1", "payload"}, + Session::PermissionBlock{"p1", "tool-p1", "Edit main.cpp", "edit", {}}, + Session::PlanBlock{{Session::PlanEntry{"Step", "high", "pending"}}}}; + + Session::ConversationHistory history; + history.append(system); + history.append(user); + history.append(assistant); + return history; +} + +QList projectedKinds() +{ + QList kinds; + for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) + kinds.append(row.kind); + return kinds; +} + +QList survivingKinds(Session::RowAudience audience) +{ + QList kept; + for (const Session::MessageRow &row : Session::projectToRows(historyWithEveryRowKind())) { + if (Session::rowTreatmentFor(audience, row.kind) != Session::RowTreatment::Omit) + kept.append(row.kind); + } + return kept; +} + } // namespace Context::DocumentContextReader QodeAssistTest::createReader(const QString &text) @@ -211,7 +366,6 @@ QSharedPointer QodeAssistTest::createSettingsF { auto settings = QSharedPointer::create(); settings->readFullFile.setValue(true, Utils::BaseAspect::BeQuiet); - settings->useProjectChangesCache.setValue(false, Utils::BaseAspect::BeQuiet); return settings; } @@ -222,7 +376,6 @@ QSharedPointer QodeAssistTest::createSettingsF settings->readFullFile.setValue(false, Utils::BaseAspect::BeQuiet); settings->readStringsBeforeCursor.setValue(linesBefore, Utils::BaseAspect::BeQuiet); settings->readStringsAfterCursor.setValue(linesAfter, Utils::BaseAspect::BeQuiet); - settings->useProjectChangesCache.setValue(false, Utils::BaseAspect::BeQuiet); return settings; } @@ -1171,7 +1324,7 @@ void QodeAssistTest::testHistoryAppliesToChatModel() { Chat::ChatModel model; Session::Session session; - Chat::ChatHistoryBridge bridge(&session, &model); + attachModelToSession(session, model); session.setHistory(historyWithoutFileEdits()); @@ -1239,13 +1392,11 @@ void QodeAssistTest::testSessionAppendsUserTurnBeforeSending() session.sendTurn( {Session::TextBlock{"explain this"}, Session::AttachmentBlock{"notes.txt", "notes_ab.txt"}}, - std::nullopt, - Session::TurnOptions{true, false}); + std::nullopt); QCOMPARE(backend.turnCount(), 1); QCOMPARE(backend.historySizeAtSend(), qsizetype(1)); QCOMPARE(backend.lastUserBlocks().size(), 2); - QCOMPARE(backend.lastOptions(), (Session::TurnOptions{true, false})); QVERIFY(!backend.lastContext().has_value()); QCOMPARE(session.history().size(), 1); @@ -1261,7 +1412,7 @@ void QodeAssistTest::testSessionStreamsTextThinkingAndTools() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); backend.script(scriptedTurn("r1")); QCOMPARE(session.history().size(), 2); @@ -1278,7 +1429,12 @@ void QodeAssistTest::testSessionStreamsTextThinkingAndTools() QCOMPARE( assistant.blocks.at(2), (Session::ContentBlock{Session::ToolCallBlock{ - "t1", "read_file", QJsonObject{{"path", "main.cpp"}}, "int main() {}"}})); + "t1", + "read_file", + QJsonObject{{"path", "main.cpp"}}, + "int main() {}", + {}, + "completed"}})); QCOMPARE( assistant.blocks.at(3), (Session::ContentBlock{Session::TextBlock{"here is the answer"}})); } @@ -1289,7 +1445,7 @@ void QodeAssistTest::testSessionTrimsStreamedText() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "\n\n"}, @@ -1308,7 +1464,7 @@ void QodeAssistTest::testSessionAbandoningTheTurnCancelsTheBackend() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); const int cancelsBeforeTruncate = backend.cancelCount(); @@ -1320,15 +1476,62 @@ void QodeAssistTest::testSessionAbandoningTheTurnCancelsTheBackend() QCOMPARE(backend.cancelCount(), cancelsBeforeLoad + 1); } +void QodeAssistTest::testSwitchingBackendsCancelsTheActiveTurn() +{ + FakeChatBackend first; + FakeChatBackend second; + Session::Session session; + session.setBackend(&first); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + first.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); + + const int cancelsBeforeSwap = first.cancelCount(); + session.setBackend(&second); + QCOMPARE(first.cancelCount(), cancelsBeforeSwap + 1); + + const qsizetype rowsAfterSwap = session.rows().size(); + first.script({Session::TextDelta{"r1", " stale"}}); + QCOMPARE(session.rows().size(), rowsAfterSwap); + QCOMPARE(session.rows().at(1).content, QString("partial")); +} + +void QodeAssistTest::testSessionForwardsTitleFromTheEventStream() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + QStringList titles; + connect( + &session, + &Session::Session::sessionInfoReceived, + this, + [&titles](const QString &title) { titles.append(title); }); + + session.sendTurn({Session::TextBlock{"hi"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::TextDelta{"r1", "hello"}, + Session::SessionInfo{.title = "Working title"}, + Session::TurnCompleted{"r1"}, + Session::SessionInfo{.title = "Chat about parsers"}}); + + QCOMPARE(titles, (QStringList{"Working title", "Chat about parsers"})); + + backend.script({Session::SessionInfo{.title = QString()}}); + QCOMPARE(titles.size(), 2); +} + void QodeAssistTest::testSessionProjectionMatchesHistory() { Chat::ChatModel model; FakeChatBackend backend; Session::Session session; - Chat::ChatHistoryBridge bridge(&session, &model); + attachModelToSession(session, model); session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); backend.script(scriptedTurn("r1")); const QList expected = Session::projectToRows(session.history()); @@ -1351,7 +1554,7 @@ void QodeAssistTest::testSessionAccumulatesStreamedThinking() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, Session::ThinkingReceived{"r1", "step", QString(), false}, @@ -1373,12 +1576,12 @@ void QodeAssistTest::testSessionKeepsThinkingAfterToolContinuation() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, Session::ThinkingReceived{"r1", "first thought", "sig-a", false}, - Session::ToolCallStarted{"r1", "t1", "read_file", QJsonObject{}, false}, - Session::ToolCallCompleted{"r1", "t1", "read_file", "contents"}, + llmToolStarted("r1", "t1", "read_file"), + llmToolCompleted("r1", "t1", "read_file", "contents"), Session::ThinkingReceived{"r1", "second thought", "sig-b", false}, Session::TextDelta{"r1", "done"}}); @@ -1398,20 +1601,20 @@ void QodeAssistTest::testSessionDropsPreToolTextWhenAsked() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "leaked tool preamble"}, - Session::ToolCallStarted{"r1", "t1", "read_file", QJsonObject{}, true}, - Session::ToolCallCompleted{"r1", "t1", "read_file", "contents"}, + llmToolStarted("r1", "t1", "read_file", {}, true), + llmToolCompleted("r1", "t1", "read_file", "contents"), Session::TextDelta{"r1", "the answer"}}); const Session::Message &assistant = session.history().at(1); QCOMPARE(assistant.blocks.size(), 2); QCOMPARE( assistant.blocks.at(0), - (Session::ContentBlock{ - Session::ToolCallBlock{"t1", "read_file", QJsonObject{}, "contents"}})); + (Session::ContentBlock{Session::ToolCallBlock{ + "t1", "read_file", QJsonObject{}, "contents", {}, "completed"}})); QCOMPARE(assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"the answer"}})); QCOMPARE(session.rows(), Session::projectToRows(session.history())); } @@ -1425,11 +1628,11 @@ void QodeAssistTest::testSessionToolResultAppendsFileEditRow() const QString result = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, - Session::ToolCallStarted{"r1", "t1", "edit_file", QJsonObject{}, false}, - Session::ToolCallCompleted{"r1", "t1", "edit_file", result}}); + llmToolStarted("r1", "t1", "edit_file"), + llmToolCompleted("r1", "t1", "edit_file", result)}); const Session::Message &assistant = session.history().at(1); QCOMPARE(assistant.blocks.size(), 2); @@ -1443,6 +1646,259 @@ void QodeAssistTest::testSessionToolResultAppendsFileEditRow() QVERIFY(editRow.content.contains("\"status_message\":\"Successfully applied\"")); } +void QodeAssistTest::testSessionRepeatedToolResultKeepsOneFileEdit() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + const QString result + = "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\",\"status\":\"pending\"}"; + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + llmToolStarted("r1", "t1", "edit_file"), + llmToolCompleted("r1", "t1", "edit_file", result), + llmToolCompleted("r1", "t1", "edit_file", result)}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + + qsizetype editBlocks = 0; + for (const Session::ContentBlock &block : assistant.blocks) { + if (std::holds_alternative(block)) + ++editBlocks; + } + QCOMPARE(editBlocks, qsizetype(1)); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testRunningToolStatusInterruptsOnTurnEnd() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, llmToolStarted("r1", "t1", "read_file")}); + QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress")); + + session.cancel(); + + QCOMPARE(session.rows().at(1).toolStatus, QString("interrupted")); + + const auto *tool + = std::get_if(&session.history().at(1).blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->status, QString("interrupted")); +} + +void QodeAssistTest::testRunningToolStatusInterruptsOnLoad() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::ToolCallBlock{ + "call-1", "Edit main.cpp", QJsonObject{}, {}, "edit", "in_progress", {}, true}}; + + Session::ConversationHistory history; + history.append(assistant); + + const auto reloaded + = Session::HistorySerializer::fromJson(Session::HistorySerializer::toJson(history)); + QVERIFY(reloaded.has_value()); + + const auto *tool = std::get_if(&reloaded->at(0).blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->status, QString("interrupted")); + + const auto rebuilt = Session::buildFromRows(Session::projectToRows(history)); + const auto *rowTool = std::get_if(&rebuilt.at(0).blocks.at(0)); + QVERIFY(rowTool); + QCOMPARE(rowTool->status, QString("interrupted")); + + QCOMPARE(Session::restoredToolStatus("completed"), QString("completed")); + QCOMPARE(Session::restoredToolStatus("failed"), QString("failed")); + QCOMPARE(Session::restoredToolStatus(QString()), QString()); +} + +void QodeAssistTest::testAgentDiffBecomesAppliedFileEdit() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + struct Recorded + { + QString turnId; + QString editId; + QString filePath; + QString oldContent; + QString newContent; + }; + QList recorded; + connect( + &session, + &Session::Session::agentFileEditRecorded, + this, + [&recorded]( + const QString &turnId, + const QString &editId, + const QString &filePath, + const QString &oldContent, + const QString &newContent) { + recorded.append({turnId, editId, filePath, oldContent, newContent}); + }); + + const QJsonObject details{ + {"diffs", + QJsonArray{QJsonObject{ + {"path", "/proj/main.cpp"}, {"oldText", "int a;"}, {"newText", "int b;"}}}}}; + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "in_progress", + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "completed", + .details = details, + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "completed", + .details = details, + .fromAgent = true}}); + + QCOMPARE(recorded.size(), 1); + QCOMPARE(recorded.at(0).turnId, QString("r1")); + QCOMPARE(recorded.at(0).editId, QString("agent-call-1-0")); + QCOMPARE(recorded.at(0).filePath, QString("/proj/main.cpp")); + QCOMPARE(recorded.at(0).oldContent, QString("int a;")); + QCOMPARE(recorded.at(0).newContent, QString("int b;")); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + + const auto *edit = std::get_if(&assistant.blocks.at(1)); + QVERIFY(edit); + QCOMPARE(edit->id, QString("agent-call-1-0")); + + const auto payload = Session::parseFileEditPayload(edit->payload); + QVERIFY(payload.has_value()); + QCOMPARE(payload->value("status").toString(), QString("applied")); + QCOMPARE(payload->value("file").toString(), QString("/proj/main.cpp")); + QCOMPARE(payload->value("old_content").toString(), QString("int a;")); + QCOMPARE(payload->value("new_content").toString(), QString("int b;")); + + const Session::MessageRow &editRow = session.rows().last(); + QCOMPARE(editRow.kind, Session::RowKind::FileEdit); + QVERIFY(editRow.content.contains("\"status\":\"applied\"")); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testAgentDiffSkipsUnrevertableRegistrations() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + int recorded = 0; + connect( + &session, + &Session::Session::agentFileEditRecorded, + this, + [&recorded]( + const QString &, const QString &, const QString &, const QString &, const QString &) { + ++recorded; + }); + + const QJsonObject details{ + {"diffs", + QJsonArray{ + QJsonObject{{"path", "/proj/deleted.cpp"}, {"oldText", "gone"}, {"newText", ""}}, + QJsonObject{ + {"path", "/proj/big.cpp"}, + {"oldText", "int a;"}, + {"newText", "int b;"}, + {"truncated", true}}}}}; + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit files", + .kind = "edit", + .status = "completed", + .details = details, + .fromAgent = true}}); + + QCOMPARE(recorded, 0); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 3); + QVERIFY(std::get_if(&assistant.blocks.at(1))); + QVERIFY(std::get_if(&assistant.blocks.at(2))); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testAgentFileEditRevertRoundTrip() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString filePath = dir.filePath("edited.cpp"); + const QString oldContent = "int a;\n"; + const QString newContent = "int b;\n"; + + { + QFile file(filePath); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(newContent.toUtf8()); + } + + const QString editId + = "agent-test-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + const QString requestId + = "req-" + QUuid::createUuid().toString(QUuid::WithoutBraces); + + auto &changes = Context::FileEditManager::instance(); + changes.registerAppliedFileEdit(editId, filePath, oldContent, newContent, requestId); + + const auto registered = changes.getFileEdit(editId); + QCOMPARE(registered.status, Context::FileEditManager::Applied); + QCOMPARE(registered.filePath, filePath); + + QVERIFY(changes.applyPendingEditsForRequest(requestId)); + + QSignalSpy undoneSpy(&changes, &Context::FileEditManager::fileEditUndone); + QVERIFY(changes.undoFileEdit(editId)); + QCOMPARE(undoneSpy.count(), 1); + QCOMPARE(undoneSpy.at(0).at(0).toString(), editId); + + QFile file(filePath); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + QCOMPARE(QString::fromUtf8(file.readAll()), oldContent); + + QCOMPARE(changes.getFileEdit(editId).status, Context::FileEditManager::Rejected); +} + void QodeAssistTest::testSessionIgnoresEchoedFileEditMarker() { FakeChatBackend backend; @@ -1453,11 +1909,11 @@ void QodeAssistTest::testSessionIgnoresEchoedFileEditMarker() = "Pre-compression history (/src): 1 matching message(s)\n\n[#3 assistant]\n" "QODEASSIST_FILE_EDIT:{\"edit_id\":\"e1\",\"file\":\"main.cpp\"}"; - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, - Session::ToolCallStarted{"r1", "t1", "read_original_history", QJsonObject{}, false}, - Session::ToolCallCompleted{"r1", "t1", "read_original_history", echoed}}); + llmToolStarted("r1", "t1", "read_original_history"), + llmToolCompleted("r1", "t1", "read_original_history", echoed)}); QCOMPARE(session.history().at(1).blocks.size(), 1); QCOMPARE(session.rows().size(), 2); @@ -1517,7 +1973,7 @@ void QodeAssistTest::testSessionUsageLandsOnTheTurn() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, Session::ThinkingReceived{"r1", "hmm", "sig", false}, @@ -1536,12 +1992,12 @@ void QodeAssistTest::testSessionIgnoresEventsFromOtherTurns() Session::Session session; session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script( {Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "mine"}, Session::TextDelta{"other", "not mine"}, - Session::ToolCallStarted{"other", "t9", "read_file", QJsonObject{}, false}}); + llmToolStarted("other", "t9", "read_file")}); QCOMPARE(session.history().size(), 2); QCOMPARE(session.history().at(1).blocks.size(), 1); @@ -1577,7 +2033,7 @@ void QodeAssistTest::testSessionIgnoresFailureFromAbandonedTurn() ++reported; }); - session.sendTurn({Session::TextBlock{"go"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); backend.script({Session::TurnStarted{"r1"}, Session::TextDelta{"r1", "partial"}}); session.truncateRows(0); @@ -1591,10 +2047,10 @@ void QodeAssistTest::testSessionTruncateRowsRewritesHistory() Chat::ChatModel model; FakeChatBackend backend; Session::Session session; - Chat::ChatHistoryBridge bridge(&session, &model); + attachModelToSession(session, model); session.setBackend(&backend); - session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt, {}); + session.sendTurn({Session::TextBlock{"explain this"}}, std::nullopt); backend.script(scriptedTurn("r1")); const qsizetype before = session.rows().size(); @@ -1609,54 +2065,773 @@ void QodeAssistTest::testSessionTruncateRowsRewritesHistory() QCOMPARE(session.rows(), Session::projectToRows(session.history())); } +void QodeAssistTest::testSessionUpsertsAgentToolCalls() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "pending", + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Edit main.cpp", + .kind = "edit", + .status = "completed", + .result = "done", + .details = QJsonObject{{"locations", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}, + .fromAgent = true}}); + + QCOMPARE(session.history().size(), 2); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 1); + + const auto *tool = std::get_if(&assistant.blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->id, QString("call-1")); + QCOMPARE(tool->name, QString("Edit main.cpp")); + QCOMPARE(tool->kind, QString("edit")); + QCOMPARE(tool->status, QString("completed")); + QCOMPARE(tool->result, QString("done")); + QVERIFY(tool->details.contains("locations")); + + QCOMPARE(session.rows().size(), 2); + QCOMPARE(session.rows().at(1).kind, Session::RowKind::AgentTool); + QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); + QCOMPARE(session.rows().at(1).toolKind, QString("edit")); + QCOMPARE(session.rows().at(1).toolDetails, tool->details); + QVERIFY(tool->fromAgent); +} + +void QodeAssistTest::testLlmToolLifecycleFlowsThroughOneEvent() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + llmToolStarted("r1", "t1", "read_file", QJsonObject{{"path", "main.cpp"}})}); + + QCOMPARE(session.rows().size(), 2); + QCOMPARE(session.rows().at(1).kind, Session::RowKind::Tool); + QCOMPARE(session.rows().at(1).toolStatus, QString("in_progress")); + + backend.script( + {Session::TextDelta{"r1", "working"}, + llmToolCompleted("r1", "t1", "read_file", "int main() {}"), + Session::TextDelta{"r1", " still"}}); + + const Session::Message &assistant = session.history().at(1); + QCOMPARE(assistant.blocks.size(), 2); + + const auto *tool = std::get_if(&assistant.blocks.at(0)); + QVERIFY(tool); + QCOMPARE(tool->status, QString("completed")); + QCOMPARE(tool->result, QString("int main() {}")); + QCOMPARE(tool->arguments, (QJsonObject{{"path", "main.cpp"}})); + QVERIFY(!tool->fromAgent); + + QCOMPARE( + assistant.blocks.at(1), (Session::ContentBlock{Session::TextBlock{"working still"}})); + QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); + QCOMPARE(session.rows(), Session::projectToRows(session.history())); +} + +void QodeAssistTest::testSessionKeepsOnePlanPerTurn() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"fix it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::PlanUpdated{ + .turnId = "r1", + .entries = {Session::PlanEntry{"Read", "high", "in_progress"}, + Session::PlanEntry{"Fix", "medium", "pending"}}}, + Session::TextDelta{"r1", "working"}, + Session::PlanUpdated{ + .turnId = "r1", + .entries = {Session::PlanEntry{"Read", "high", "completed"}, + Session::PlanEntry{"Fix", "medium", "in_progress"}}}}); + + const Session::Message &assistant = session.history().at(1); + + qsizetype planBlocks = 0; + const Session::PlanBlock *plan = nullptr; + for (const Session::ContentBlock &block : assistant.blocks) { + if (const auto *candidate = std::get_if(&block)) { + ++planBlocks; + plan = candidate; + } + } + + QCOMPARE(planBlocks, qsizetype(1)); + QVERIFY(plan); + QCOMPARE(plan->entries.size(), 2); + QCOMPARE(plan->entries.at(0).status, QString("completed")); + QCOMPARE(plan->entries.at(1).status, QString("in_progress")); + + qsizetype planRows = 0; + for (const Session::MessageRow &row : session.rows()) { + if (row.kind == Session::RowKind::Plan) + ++planRows; + } + QCOMPARE(planRows, qsizetype(1)); +} + +void QodeAssistTest::testAgentActivityKeepsStreamedTextIntact() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read main.cpp", + .status = "pending", + .fromAgent = true}, + Session::TextDelta{"r1", "Reading the file"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read main.cpp", + .status = "completed", + .fromAgent = true}, + Session::TextDelta{"r1", " and it is done."}}); + + const Session::Message &assistant = session.history().at(1); + + QStringList streamed; + for (const Session::ContentBlock &block : assistant.blocks) { + if (const auto *text = std::get_if(&block)) + streamed.append(text->text); + } + + QCOMPARE(streamed, QStringList{"Reading the file and it is done."}); +} + +void QodeAssistTest::testAgentToolUpdateTouchesOnlyItsOwnRow() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read a", + .status = "pending", + .fromAgent = true}, + Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-2", + .name = "Read b", + .status = "pending", + .fromAgent = true}}); + + QList updatedRows; + QObject::connect( + &session, + &Session::Session::rowUpdated, + &session, + [&updatedRows](int index, const Session::MessageRow &) { updatedRows.append(index); }); + + backend.script( + {Session::ToolCallUpdated{ + .turnId = "r1", + .toolId = "call-1", + .name = "Read a", + .status = "completed", + .fromAgent = true}}); + + QCOMPARE(updatedRows, QList{1}); + QCOMPARE(session.rows().at(1).toolStatus, QString("completed")); + QCOMPARE(session.rows().at(2).toolStatus, QString("pending")); +} + +void QodeAssistTest::testUsageSkipsRowsThatCannotShowIt() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"go"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + Session::PlanUpdated{ + .turnId = "r1", .entries = {Session::PlanEntry{"Read", "high", "in_progress"}}}, + Session::TextDelta{"r1", "here is the answer"}, + Session::UsageReported{"r1", Session::Usage{120, 40, 80, 12}}}); + + const Session::MessageRow &planRow = session.rows().at(1); + const Session::MessageRow &textRow = session.rows().at(2); + + QCOMPARE(planRow.kind, Session::RowKind::Plan); + QVERIFY(planRow.usage.isEmpty()); + + QCOMPARE(textRow.kind, Session::RowKind::Assistant); + QCOMPARE(textRow.usage, (Session::Usage{120, 40, 80, 12})); +} + +void QodeAssistTest::testAgentActivityBlocksSurviveReload() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::PlanBlock{ + {Session::PlanEntry{"Read", "high", "completed"}, + Session::PlanEntry{"Fix", "medium", "in_progress"}}}, + Session::ToolCallBlock{ + "call-1", + "Edit main.cpp", + QJsonObject{}, + "done", + "edit", + "completed", + QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}, + true}}; + + Session::ConversationHistory history; + history.append(assistant); + + const auto reloaded = Session::HistorySerializer::fromJson( + Session::HistorySerializer::toJson(history)); + QVERIFY(reloaded.has_value()); + QCOMPARE(*reloaded, history); + + const QList rows = Session::projectToRows(history); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(0).kind, Session::RowKind::Plan); + QCOMPARE(rows.at(1).kind, Session::RowKind::AgentTool); + QVERIFY(Session::isTranscriptOnlyRow(rows.at(0).kind)); + QVERIFY(Session::isTranscriptOnlyRow(rows.at(1).kind)); + QCOMPARE(Session::buildFromRows(rows), history); +} + +void QodeAssistTest::testEveryRowKindProjectsInOrder() +{ + QCOMPARE( + projectedKinds(), + (QList{ + Session::RowKind::System, + Session::RowKind::User, + Session::RowKind::Assistant, + Session::RowKind::Thinking, + Session::RowKind::Tool, + Session::RowKind::AgentTool, + Session::RowKind::FileEdit, + Session::RowKind::Permission, + Session::RowKind::Plan})); +} + +void QodeAssistTest::testPromptAudienceKeepsToolsAndThinking() +{ + QCOMPARE( + survivingKinds(Session::RowAudience::Prompt), + (QList{ + Session::RowKind::System, + Session::RowKind::User, + Session::RowKind::Assistant, + Session::RowKind::Thinking, + Session::RowKind::Tool})); + + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::User), + Session::RowTreatment::UserText); + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::System), + Session::RowTreatment::AssistantText); + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Thinking), + Session::RowTreatment::AssistantThinking); + QCOMPARE( + Session::rowTreatmentFor(Session::RowAudience::Prompt, Session::RowKind::Tool), + Session::RowTreatment::ToolExchange); +} + +void QodeAssistTest::testCompressionAudienceDropsToolsAndThinking() +{ + QCOMPARE( + survivingKinds(Session::RowAudience::Compression), + (QList{ + Session::RowKind::System, Session::RowKind::User, Session::RowKind::Assistant})); +} + +void QodeAssistTest::testTokenCountAudienceCountsToolsAndThinking() +{ + QCOMPARE( + survivingKinds(Session::RowAudience::TokenCount), + (QList{ + Session::RowKind::System, + Session::RowKind::User, + Session::RowKind::Assistant, + Session::RowKind::Thinking, + Session::RowKind::Tool})); +} + +void QodeAssistTest::testSessionRendersPermissionRequestWithItsOptions() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + QCOMPARE(session.rows().size(), 2); + QCOMPARE(session.rows().at(1).kind, Session::RowKind::Permission); + QCOMPARE(session.rows().at(1).id, QString("p1")); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.requestId, QString("p1")); + QCOMPARE(block.toolCallId, QString("tool-p1")); + QCOMPARE(block.title, QString("Edit main.cpp")); + QCOMPARE(block.toolKind, QString("edit")); + QCOMPARE(block.options, editPermissionOptions()); + QCOMPARE(block.status, Session::PermissionStatus::Pending); + QVERIFY(!block.automatic); + QVERIFY(session.isPermissionPending("p1")); + + QVERIFY(backend.permissionAnswers().isEmpty()); +} + +void QodeAssistTest::testSessionForwardsPermissionAnswerToTheBackend() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("p1", "opt-no"); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(backend.permissionAnswers().at(0).first, QString("p1")); + QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-no")); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.status, Session::PermissionStatus::Answered); + QCOMPARE(block.selectedOptionId, QString("opt-no")); + QVERIFY(!block.automatic); + QVERIFY(!session.isPermissionPending("p1")); +} + +void QodeAssistTest::testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit them"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("p1", "opt-always"); + + backend.script({editPermissionRequest("r1", "p2")}); + + QCOMPARE(backend.permissionAnswers().size(), 2); + QCOMPARE(backend.permissionAnswers().at(1).first, QString("p2")); + QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-once")); + + const Session::PermissionBlock block = permissionRowAt(session, 2); + QCOMPARE(block.requestId, QString("p2")); + QCOMPARE(block.status, Session::PermissionStatus::Answered); + QCOMPARE(block.selectedOptionId, QString("opt-once")); + QVERIFY(block.automatic); + QVERIFY(!session.isPermissionPending("p2")); +} + +void QodeAssistTest::testSessionAllowAlwaysDoesNotCoverOtherToolKinds() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")}); + + session.respondPermission("p1", "opt-always"); + + backend.script({editPermissionRequest("r1", "p2", "execute")}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); + QVERIFY(session.isPermissionPending("p2")); +} + +void QodeAssistTest::testSessionNeverAutoAnswersUnclassifiedTools() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", QString())}); + + session.respondPermission("p1", "opt-always"); + + backend.script({editPermissionRequest("r1", "p2", QString())}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); +} + +void QodeAssistTest::testSessionIgnoresAnswersToUnknownPermissionRequests() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("nonexistent", "opt-once"); + session.respondPermission("p1", "no-such-option"); + session.respondPermission("p1", "opt-once"); + session.respondPermission("p1", "opt-no"); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(backend.permissionAnswers().at(0).second, QString("opt-once")); + QCOMPARE(permissionRowAt(session, 1).selectedOptionId, QString("opt-once")); +} + +void QodeAssistTest::testSessionMarksUnansweredPermissionsCancelled() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + backend.script( + {Session::PermissionResolved{.turnId = "r1", .requestId = "p1", .cancelled = true}, + Session::TurnFailed{"r1", "the agent went away"}}); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.status, Session::PermissionStatus::Cancelled); + QVERIFY(block.selectedOptionId.isEmpty()); + QVERIFY(!session.isPermissionPending("p1")); + + session.respondPermission("p1", "opt-once"); + QVERIFY(backend.permissionAnswers().isEmpty()); +} + +void QodeAssistTest::testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + Session::PermissionRequested first = editPermissionRequest("r1", "p1"); + first.options.append( + Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"}); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, first}); + + session.respondPermission("p1", "opt-never"); + + Session::PermissionRequested second = editPermissionRequest("r1", "p2"); + second.options.append( + Session::PermissionOption{"opt-never", "No, and don't ask again", "reject_always"}); + backend.script({second}); + + QCOMPARE(backend.permissionAnswers().size(), 2); + QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-no")); + + const Session::PermissionBlock block = permissionRowAt(session, 2); + QCOMPARE(block.status, Session::PermissionStatus::Answered); + QCOMPARE(block.selectedOptionId, QString("opt-no")); + QVERIFY(block.automatic); +} + +void QodeAssistTest::testSessionAutoAnswerFallsBackToTheAlwaysOption() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + session.respondPermission("p1", "opt-always"); + + Session::PermissionRequested withoutOnce = editPermissionRequest("r1", "p2"); + withoutOnce.options = { + Session::PermissionOption{"opt-always", "Yes, always", "allow_always"}, + Session::PermissionOption{"opt-no", "No", "reject_once"}}; + backend.script({withoutOnce}); + + QCOMPARE(backend.permissionAnswers().size(), 2); + QCOMPARE(backend.permissionAnswers().at(1).second, QString("opt-always")); +} + +void QodeAssistTest::testSessionDoesNotAutoAnswerWithoutAMatchingOption() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + session.respondPermission("p1", "opt-always"); + + Session::PermissionRequested rejectOnly = editPermissionRequest("r1", "p2"); + rejectOnly.options = {Session::PermissionOption{"opt-no", "No", "reject_once"}}; + backend.script({rejectOnly}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); + QVERIFY(session.isPermissionPending("p2")); +} + +void QodeAssistTest::testSessionAllowAlwaysDoesNotSurviveAnotherConversation() +{ + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"work"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + session.respondPermission("p1", "opt-always"); + QCOMPARE(backend.permissionAnswers().size(), 1); + + session.setHistory(Session::ConversationHistory{}); + + session.sendTurn({Session::TextBlock{"work again"}}, std::nullopt); + backend.script({Session::TurnStarted{"r2"}, editPermissionRequest("r2", "p2")}); + + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Pending); +} + +void QodeAssistTest::testSessionAnswersTheLiveRequestWhenAnOldIdIsReused() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "old"; + assistant.blocks + = {Session::PermissionBlock{ + .requestId = "p1", + .toolCallId = "tool-old", + .title = "Run make", + .toolKind = "execute", + .options = editPermissionOptions(), + .status = Session::PermissionStatus::Cancelled}}; + + Session::ConversationHistory history; + history.append(assistant); + + FakeChatBackend backend; + Session::Session session; + session.setBackend(&backend); + session.setHistory(history); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1", "edit")}); + + session.respondPermission("p1", "opt-always"); + + QCOMPARE(backend.permissionAnswers().size(), 1); + + const Session::PermissionBlock stale = permissionRowAt(session, 0); + QCOMPARE(stale.status, Session::PermissionStatus::Cancelled); + QVERIFY(stale.selectedOptionId.isEmpty()); + + const Session::PermissionBlock live = permissionRowAt(session, 2); + QCOMPARE(live.status, Session::PermissionStatus::Answered); + QCOMPARE(live.selectedOptionId, QString("opt-always")); + + backend.script({editPermissionRequest("r1", "p3", "edit")}); + QCOMPARE(backend.permissionAnswers().size(), 2); + + backend.script({editPermissionRequest("r1", "p4", "execute")}); + QCOMPARE(backend.permissionAnswers().size(), 2); +} + +void QodeAssistTest::testSessionCancelsPermissionsTheBackendNeverResolved() +{ + FakeChatBackend backend; + backend.setAutoResolvePermissions(false); + + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script( + {Session::TurnStarted{"r1"}, + editPermissionRequest("r1", "p1"), + Session::TurnCompleted{"r1"}}); + + QCOMPARE(permissionRowAt(session, 1).status, Session::PermissionStatus::Cancelled); + QVERIFY(!session.isPermissionPending("p1")); +} + +void QodeAssistTest::testSessionCancelsPermissionsTheBackendRefused() +{ + FakeChatBackend backend; + backend.setAcceptPermissionAnswers(false); + + Session::Session session; + session.setBackend(&backend); + + session.sendTurn({Session::TextBlock{"edit it"}}, std::nullopt); + backend.script({Session::TurnStarted{"r1"}, editPermissionRequest("r1", "p1")}); + + session.respondPermission("p1", "opt-always"); + + const Session::PermissionBlock block = permissionRowAt(session, 1); + QCOMPARE(block.status, Session::PermissionStatus::Cancelled); + QVERIFY(block.selectedOptionId.isEmpty()); + QVERIFY(!session.isPermissionPending("p1")); + + backend.script({editPermissionRequest("r1", "p2")}); + QCOMPARE(backend.permissionAnswers().size(), 1); + QCOMPARE(permissionRowAt(session, 2).status, Session::PermissionStatus::Pending); +} + +void QodeAssistTest::testPermissionBlockReopensAsUnanswerable() +{ + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r1"; + assistant.blocks + = {Session::PermissionBlock{ + .requestId = "p1", + .toolCallId = "tool-1", + .title = "Edit main.cpp", + .toolKind = "edit", + .options = editPermissionOptions(), + .status = Session::PermissionStatus::Answered, + .selectedOptionId = "opt-once", + .automatic = false}, + Session::PermissionBlock{ + .requestId = "p2", + .toolCallId = "tool-2", + .title = "Run make", + .toolKind = "execute", + .options = editPermissionOptions(), + .status = Session::PermissionStatus::Pending}}; + + Session::ConversationHistory history; + history.append(assistant); + + const QJsonObject json = Session::HistorySerializer::toJson(history); + const auto reloaded = Session::HistorySerializer::fromJson(json); + QVERIFY(reloaded.has_value()); + QCOMPARE(reloaded->size(), 1); + QCOMPARE(reloaded->at(0).blocks.size(), 2); + + const auto *answered = std::get_if(&reloaded->at(0).blocks.at(0)); + QVERIFY(answered); + QCOMPARE(*answered, std::get(assistant.blocks.at(0))); + + const auto *stale = std::get_if(&reloaded->at(0).blocks.at(1)); + QVERIFY(stale); + QCOMPARE(stale->status, Session::PermissionStatus::Cancelled); + QCOMPARE(stale->options, editPermissionOptions()); + + const QList rows = Session::projectToRows(*reloaded); + QCOMPARE(rows.size(), 2); + QCOMPARE(rows.at(0).kind, Session::RowKind::Permission); + + const Session::ConversationHistory rebuilt = Session::buildFromRows(rows); + QCOMPARE(rebuilt.size(), 1); + QCOMPARE(rebuilt.at(0).blocks, reloaded->at(0).blocks); +} + void QodeAssistTest::testTurnContextCollectsPartsFromPorts() { - FakeProjectContext projectPort(sampleProject(), "Always use tabs."); + FakeProjectContext projectPort(sampleProject()); FakeSkillsContext skillsPort( "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); - FakeLinkedFiles filesPort({Session::LinkedFile{"main.cpp", "int main() {}"}}); Session::TurnContextRequest request; request.message = "please /review this"; request.basePrompt = "You are a helpful assistant."; - request.rolePrompt = "Act as a reviewer."; - request.linkedFilePaths = QList{"/src/main.cpp"}; const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, &skillsPort, filesPort).build(request); + = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); QCOMPARE(context.basePrompt, QString("You are a helpful assistant.")); - QVERIFY(context.rolePrompt.has_value()); - QCOMPARE(*context.rolePrompt, QString("Act as a reviewer.")); QVERIFY(context.project.available); QCOMPARE(context.project.displayName, QString("QodeAssist")); - QCOMPARE(context.projectRules, QString("Always use tabs.")); QCOMPARE(context.alwaysOnSkills, QString("# Always on")); QCOMPARE(context.skillsCatalog, QString("# Skills catalog")); QCOMPARE(context.invokedSkills.size(), 1); QCOMPARE(context.invokedSkills.at(0).name, QString("review")); - QCOMPARE(context.linkedFilePaths, (QList{"/src/main.cpp"})); - QCOMPARE(context.linkedFiles.size(), 1); - QCOMPARE(context.linkedFiles.at(0).fileName, QString("main.cpp")); - QCOMPARE(filesPort.requestedPaths(), (QList{"/src/main.cpp"})); +} + +void QodeAssistTest::testTurnContextSkipsWhatTheBackendDoesNotNeed() +{ + FakeProjectContext projectPort(sampleProject()); + FakeSkillsContext skillsPort( + "# Always on", "# Skills catalog", {Session::InvokedSkill{"review", "Review body"}}); + + Session::TurnContextRequest request; + request.message = "please /review this"; + request.needs = Session::TurnContextNeeds{false}; + + const Session::TurnContext context + = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); + + QVERIFY(context.alwaysOnSkills.isEmpty()); + QVERIFY(context.skillsCatalog.isEmpty()); + QVERIFY(context.invokedSkills.isEmpty()); +} + +void QodeAssistTest::testFencedFileBlockOutgrowsBackticksInContent() +{ + const QString plain = Session::fencedFileBlock("notes.txt", "hello"); + QCOMPARE(plain, QString("File: notes.txt\n```\nhello\n```")); + + const QString fenced = Session::fencedFileBlock("readme.md", "text\n```\ncode\n```\nmore"); + QVERIFY(fenced.startsWith("File: readme.md\n````\n")); + QVERIFY(fenced.endsWith("\n````")); + QVERIFY(fenced.contains("```\ncode\n```")); + + const QString nested = Session::fencedFileBlock("deep.md", "`````"); + QVERIFY(nested.contains("``````")); } void QodeAssistTest::testTurnContextSkillCommandScanning() { - FakeProjectContext projectPort({}, QString()); + FakeProjectContext projectPort({}); FakeSkillsContext skillsPort( QString(), QString(), {Session::InvokedSkill{"review", "Review body"}, Session::InvokedSkill{"docs", "Docs body"}, Session::InvokedSkill{"blank", QString()}}); - FakeLinkedFiles filesPort({}); Session::TurnContextRequest request; request.message = "/review then /docs then /review again /blank /unknown and mid/word"; const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, &skillsPort, filesPort).build(request); + = Session::TurnContextBuilder(projectPort, &skillsPort).build(request); QCOMPARE(context.invokedSkills.size(), 2); QCOMPARE(context.invokedSkills.at(0).name, QString("review")); @@ -1665,70 +2840,40 @@ void QodeAssistTest::testTurnContextSkillCommandScanning() void QodeAssistTest::testTurnContextWithoutSkillsPort() { - FakeProjectContext projectPort(sampleProject(), QString()); - FakeLinkedFiles filesPort({Session::LinkedFile{"main.cpp", "int main() {}"}}); + FakeProjectContext projectPort(sampleProject()); Session::TurnContextRequest request; request.message = "/review this"; request.basePrompt = "base"; const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, nullptr, filesPort).build(request); + = Session::TurnContextBuilder(projectPort, nullptr).build(request); QVERIFY(context.alwaysOnSkills.isEmpty()); QVERIFY(context.skillsCatalog.isEmpty()); QVERIFY(context.invokedSkills.isEmpty()); - QVERIFY(context.linkedFiles.isEmpty()); - QVERIFY(filesPort.requestedPaths().isEmpty()); -} - -void QodeAssistTest::testLinkedFilesHeaderSurvivesUnreadablePaths() -{ - FakeProjectContext projectPort({}, QString()); - FakeLinkedFiles filesPort({}); - - Session::TurnContextRequest request; - request.basePrompt = "base"; - request.linkedFilePaths = QList{"/src/ignored.cpp"}; - - const Session::TurnContext context - = Session::TurnContextBuilder(projectPort, nullptr, filesPort).build(request); - - QVERIFY(context.linkedFiles.isEmpty()); - QCOMPARE(context.linkedFilePaths, (QList{"/src/ignored.cpp"})); - QCOMPARE( - Session::renderSystemPrompt(context), - QString("base\n# No active project in IDE\n\nLinked files for reference:\n")); } void QodeAssistTest::testSystemPromptRenderingWithProject() { Session::TurnContext context; context.basePrompt = "You are helpful."; - context.rolePrompt = "Be terse."; context.project = sampleProject(); - context.projectRules = "Use tabs."; context.alwaysOnSkills = "# Always on"; context.skillsCatalog = "# Catalog"; context.invokedSkills = {Session::InvokedSkill{"review", "Review body"}}; - context.linkedFilePaths = QList{"/src/main.cpp"}; - context.linkedFiles = {Session::LinkedFile{"main.cpp", "int main() {}"}}; const QString expected = "You are helpful." - "\n\nBe terse." "\n# Active project: QodeAssist" "\n# Project source root: /src/QodeAssist" "\n# All new source files, headers, QML and CMake edits MUST be created or modified " "under this directory. Use absolute paths rooted here, or project-relative paths." "\n# Build output directory (compiler artifacts only — do NOT create or edit source " "files here): /src/QodeAssist/build" - "\n# Project Rules\n\nUse tabs." "\n\n# Always on" "\n\n# Catalog" - "\n\n# Invoked Skill: review\n\nReview body" - "\n\nLinked files for reference:\n" - "\nFile: main.cpp\nContent:\nint main() {}\n"; + "\n\n# Invoked Skill: review\n\nReview body"; QCOMPARE(Session::renderSystemPrompt(context), expected); } @@ -1743,4 +2888,2549 @@ void QodeAssistTest::testSystemPromptRenderingWithoutProject() QString("You are helpful.\n# No active project in IDE")); } +void QodeAssistTest::testAgentRegistryParsesEveryDistributionKind() +{ + const QByteArray payload = R"({"version":"1.0.0","agents":[ + {"id":"npx-agent","name":"Npx Agent","version":"1.2.3","description":"An agent", + "authors":["Ada","Grace"],"license":"MIT","repository":"https://example.com/repo", + "website":"https://example.com","icon":"https://example.com/icon.svg", + "distribution":{"npx":{"package":"pkg@1.2.3","args":["--acp"],"env":{"KEY":"VALUE"}}}}, + {"id":"uvx-agent","name":"Uvx Agent", + "distribution":{"uvx":{"package":"pypkg==1.0","args":["-x"]}}}, + {"id":"binary-agent","name":"Binary Agent","distribution":{"binary":{ + "darwin-aarch64":{"archive":"https://example.com/a.tar.gz","sha256":"abc", + "cmd":"./dist/agent","args":["acp"]}}}}, + {"id":"command-agent","name":"Command Agent", + "distribution":{"command":{"cmd":"/usr/local/bin/agent","args":["acp"]}}} + ]})"; + + const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( + payload, Acp::AgentSource::LiveRegistry, "registry"); + + QVERIFY(result.warnings.isEmpty()); + QCOMPARE(result.agents.size(), 4); + + const Acp::AgentDefinition &npx = result.agents.at(0); + QCOMPARE(npx.id, QString("npx-agent")); + QCOMPARE(npx.name, QString("Npx Agent")); + QCOMPARE(npx.version, QString("1.2.3")); + QCOMPARE(npx.description, QString("An agent")); + QCOMPARE(npx.authors, (QStringList{"Ada", "Grace"})); + QCOMPARE(npx.license, QString("MIT")); + QCOMPARE(npx.repository, QString("https://example.com/repo")); + QCOMPARE(npx.source, Acp::AgentSource::LiveRegistry); + QCOMPARE(npx.origin, QString("registry")); + QCOMPARE(npx.distribution.kind, Acp::AgentDistributionKind::Npx); + QCOMPARE(npx.distribution.package, QString("pkg@1.2.3")); + QCOMPARE(npx.distribution.args, (QStringList{"--acp"})); + QCOMPARE(npx.distribution.env.size(), 1); + QCOMPARE(npx.distribution.env.at(0).name, QString("KEY")); + QCOMPARE(npx.distribution.env.at(0).value, QString("VALUE")); + QVERIFY(npx.isLaunchable()); + + const Acp::AgentDefinition &uvx = result.agents.at(1); + QCOMPARE(uvx.distribution.kind, Acp::AgentDistributionKind::Uvx); + QCOMPARE(uvx.distribution.package, QString("pypkg==1.0")); + QVERIFY(uvx.isLaunchable()); + + const Acp::AgentDefinition &binary = result.agents.at(2); + QCOMPARE(binary.distribution.kind, Acp::AgentDistributionKind::Binary); + QCOMPARE(binary.distribution.binaryTargets.size(), 1); + QCOMPARE(binary.distribution.binaryTargets.at(0).platform, QString("darwin-aarch64")); + QCOMPARE(binary.distribution.binaryTargets.at(0).cmd, QString("./dist/agent")); + QCOMPARE(binary.distribution.binaryTargets.at(0).sha256, QString("abc")); + QVERIFY(!binary.isLaunchable()); + + const Acp::AgentDefinition &command = result.agents.at(3); + QCOMPARE(command.distribution.kind, Acp::AgentDistributionKind::Command); + QCOMPARE(command.distribution.command, QString("/usr/local/bin/agent")); + QVERIFY(command.isLaunchable()); +} + +void QodeAssistTest::testAgentRegistryReportsUnusableEntries() +{ + const QByteArray payload = R"({"agents":[ + {"name":"No Id","distribution":{"npx":{"package":"p"}}}, + {"id":"no-distribution","name":"No Distribution"}, + {"id":"usable","name":"Usable","distribution":{"npx":{"package":"p"}}} + ]})"; + + const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( + payload, Acp::AgentSource::LiveRegistry, "registry"); + + QCOMPARE(result.agents.size(), 2); + QCOMPARE(result.warnings.size(), 2); + QVERIFY(result.warnings.at(0).contains("entry 0")); + QVERIFY(result.warnings.at(1).contains("no-distribution")); + + QCOMPARE(result.agents.at(0).id, QString("no-distribution")); + QVERIFY(!result.agents.at(0).isLaunchable()); + QVERIFY(result.agents.at(1).isLaunchable()); +} + +void QodeAssistTest::testAgentRegistryParsesSingleAgentUserFile() +{ + const QByteArray payload + = R"({"id":"local","distribution":{"command":{"cmd":"/opt/local-agent"}}})"; + + const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( + payload, Acp::AgentSource::UserFile, "local.json"); + + QCOMPARE(result.agents.size(), 1); + QCOMPARE(result.agents.at(0).id, QString("local")); + QCOMPARE(result.agents.at(0).name, QString("local")); + QCOMPARE(result.agents.at(0).source, Acp::AgentSource::UserFile); + QVERIFY(result.agents.at(0).isLaunchable()); +} + +void QodeAssistTest::testAgentCatalogAppliesMergePriority() +{ + const QByteArray bundled = R"({"agents":[ + {"id":"alpha","name":"Alpha Bundled","distribution":{"npx":{"package":"alpha@1"}}}, + {"id":"beta","name":"Beta","distribution":{"npx":{"package":"beta@1"}}} + ]})"; + const QByteArray live = R"({"agents":[ + {"id":"alpha","name":"Alpha Registry","distribution":{"npx":{"package":"alpha@2"}}}, + {"id":"gamma","name":"Gamma","distribution":{"uvx":{"package":"gamma"}}} + ]})"; + const QByteArray user + = R"({"id":"alpha","name":"Alpha User","distribution":{"command":{"cmd":"/opt/alpha"}}})"; + + Acp::AgentCatalog catalog; + catalog.setLayer( + Acp::AgentSource::BundledSnapshot, + Acp::AgentRegistryParser::parse(bundled, Acp::AgentSource::BundledSnapshot, "bundled") + .agents); + catalog.setLayer( + Acp::AgentSource::LiveRegistry, + Acp::AgentRegistryParser::parse(live, Acp::AgentSource::LiveRegistry, "registry").agents); + catalog.setLayer( + Acp::AgentSource::UserFile, + Acp::AgentRegistryParser::parse(user, Acp::AgentSource::UserFile, "alpha.json").agents); + + QCOMPARE(catalog.size(), 3); + + QStringList names; + for (const Acp::AgentDefinition &agent : catalog.agents()) + names.append(agent.name); + QCOMPARE(names, (QStringList{"Alpha User", "Beta", "Gamma"})); + + auto alpha = catalog.agent("alpha"); + QVERIFY(alpha.has_value()); + QCOMPARE(alpha->source, Acp::AgentSource::UserFile); + QCOMPARE(alpha->distribution.kind, Acp::AgentDistributionKind::Command); + + catalog.setLayer(Acp::AgentSource::UserFile, {}); + alpha = catalog.agent("alpha"); + QVERIFY(alpha.has_value()); + QCOMPARE(alpha->name, QString("Alpha Registry")); + QCOMPARE(alpha->distribution.package, QString("alpha@2")); + + catalog.setLayer(Acp::AgentSource::LiveRegistry, {}); + alpha = catalog.agent("alpha"); + QVERIFY(alpha.has_value()); + QCOMPARE(alpha->name, QString("Alpha Bundled")); +} + +void QodeAssistTest::testAgentCatalogUserFileMakesBinaryAgentLaunchable() +{ + const QByteArray live = R"({"agents":[{"id":"cursor","name":"Cursor", + "distribution":{"binary":{"darwin-aarch64":{"archive":"https://example.com/a.tar.gz", + "cmd":"./cursor-agent","args":["acp"]}}}}]})"; + const QByteArray user = R"({"id":"cursor","name":"Cursor", + "distribution":{"command":{"cmd":"/usr/local/bin/cursor-agent","args":["acp"]}}})"; + + Acp::AgentCatalog catalog; + catalog.setLayer( + Acp::AgentSource::LiveRegistry, + Acp::AgentRegistryParser::parse(live, Acp::AgentSource::LiveRegistry, "registry").agents); + QVERIFY(catalog.launchableAgents().isEmpty()); + + catalog.setLayer( + Acp::AgentSource::UserFile, + Acp::AgentRegistryParser::parse(user, Acp::AgentSource::UserFile, "cursor.json").agents); + + QCOMPARE(catalog.size(), 1); + QCOMPARE(catalog.launchableAgents().size(), 1); + QCOMPARE(catalog.agent("cursor")->distribution.command, QString("/usr/local/bin/cursor-agent")); +} + +void QodeAssistTest::testAgentLaunchConfigUsesRunnerConventions() +{ + Acp::AgentDefinition npx; + npx.id = "npx-agent"; + npx.distribution.kind = Acp::AgentDistributionKind::Npx; + npx.distribution.package = "pkg@1.0"; + npx.distribution.args = QStringList{"--acp"}; + npx.distribution.env = {{"KEY", "VALUE"}}; + + const auto npxConfig = Acp::agentLaunchConfig(npx, "/tmp/project"); + QVERIFY(npxConfig.has_value()); + QCOMPARE(npxConfig->cwd, QString("/tmp/project")); + QCOMPARE(npxConfig->command, QString("npx")); + QCOMPARE(npxConfig->args, (QStringList{"-y", "pkg@1.0", "--acp"})); + QCOMPARE(npxConfig->env.size(), 1); + QCOMPARE(npxConfig->env.at(0).name, QString("KEY")); + + Acp::AgentDefinition uvx; + uvx.id = "uvx-agent"; + uvx.distribution.kind = Acp::AgentDistributionKind::Uvx; + uvx.distribution.package = "pypkg==1.0"; + uvx.distribution.args = QStringList{"-x"}; + + const auto uvxConfig = Acp::agentLaunchConfig(uvx, QString()); + QVERIFY(uvxConfig.has_value()); + QCOMPARE(uvxConfig->command, QString("uvx")); + QCOMPARE(uvxConfig->args, (QStringList{"pypkg==1.0", "-x"})); + + Acp::AgentDefinition command; + command.id = "command-agent"; + command.distribution.kind = Acp::AgentDistributionKind::Command; + command.distribution.command = "/opt/agent"; + command.distribution.args = QStringList{"acp"}; + + const auto commandConfig = Acp::agentLaunchConfig(command, QString()); + QVERIFY(commandConfig.has_value()); + QCOMPARE(commandConfig->command, QString("/opt/agent")); + QCOMPARE(commandConfig->args, (QStringList{"acp"})); + + Acp::AgentDefinition binary; + binary.id = "binary-agent"; + binary.distribution.kind = Acp::AgentDistributionKind::Binary; + QVERIFY(!Acp::agentLaunchConfig(binary, QString()).has_value()); +} + +void QodeAssistTest::testAgentSearchPathsSplitting() +{ + const QString separator(QDir::listSeparator()); + + QCOMPARE(Acp::splitSearchPaths(QString()), QStringList()); + QCOMPARE( + Acp::splitSearchPaths(" /opt/homebrew/bin " + separator + separator + "/usr/local/bin"), + (QStringList{"/opt/homebrew/bin", "/usr/local/bin"})); +} + +void QodeAssistTest::testAgentExtraSearchPathsReachTheChildEnvironment() +{ + Acp::AgentDefinition agent; + agent.id = "npx-agent"; + agent.distribution.kind = Acp::AgentDistributionKind::Npx; + agent.distribution.package = "pkg"; + + auto config = Acp::agentLaunchConfig(agent, QString()); + QVERIFY(config.has_value()); + + Acp::applyExtraSearchPaths(*config, {}); + QVERIFY(config->env.isEmpty()); + QCOMPARE(config->command, QString("npx")); + + Acp::applyExtraSearchPaths(*config, QStringList{"/no/such/qodeassist/dir"}); + QCOMPARE(config->command, QString("npx")); + QCOMPARE(config->env.size(), 1); + QCOMPARE(config->env.at(0).name, QString("PATH")); + QVERIFY(config->env.at(0).value.startsWith("/no/such/qodeassist/dir")); + + Acp::AgentDefinition withOwnPath; + withOwnPath.id = "own-path"; + withOwnPath.distribution.kind = Acp::AgentDistributionKind::Npx; + withOwnPath.distribution.package = "pkg"; + withOwnPath.distribution.env = {{"PATH", "/agent/own"}}; + + auto ownPathConfig = Acp::agentLaunchConfig(withOwnPath, QString()); + QVERIFY(ownPathConfig.has_value()); + Acp::applyExtraSearchPaths(*ownPathConfig, QStringList{"/extra"}); + + QCOMPARE(ownPathConfig->env.size(), 2); + QCOMPARE(ownPathConfig->env.at(0).name, QString("PATH")); + QCOMPARE(ownPathConfig->env.at(1).value, QString("/agent/own")); +} + +void QodeAssistTest::testAgentForwardedVariablesReachTheChildEnvironment() +{ + QCOMPARE( + Acp::splitVariableNames(" FOO, BAR\tBAZ "), (QStringList{"FOO", "BAR", "BAZ"})); + QCOMPARE(Acp::splitVariableNames(QString()), QStringList()); + + qputenv("QODEASSIST_TEST_TOKEN", "from-environment"); + + Acp::AgentDefinition agent; + agent.id = "npx-agent"; + agent.distribution.kind = Acp::AgentDistributionKind::Npx; + agent.distribution.package = "pkg"; + agent.distribution.env = {{"QODEASSIST_TEST_TOKEN", "from-definition"}}; + + auto config = Acp::agentLaunchConfig(agent, QString()); + QVERIFY(config.has_value()); + + Acp::applyForwardedEnvironment(*config, {}); + QCOMPARE(config->env.size(), 1); + + Acp::applyForwardedEnvironment(*config, QStringList{"QODEASSIST_TEST_TOKEN"}); + + QCOMPARE(config->env.size(), 2); + QCOMPARE(config->env.at(0).name, QString("QODEASSIST_TEST_TOKEN")); + QCOMPARE(config->env.at(0).value, QString("from-environment")); + QCOMPARE(config->env.at(1).value, QString("from-definition")); + + qunsetenv("QODEASSIST_TEST_TOKEN"); +} + +void QodeAssistTest::testBundledAgentSnapshotParses() +{ + QFile file(Acp::AgentCatalogStore::bundledSnapshotPath()); + QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(file.fileName())); + + const Acp::AgentParseResult result = Acp::AgentRegistryParser::parse( + file.readAll(), Acp::AgentSource::BundledSnapshot, "bundled snapshot"); + + QVERIFY(result.agents.size() > 10); + QVERIFY2(result.warnings.isEmpty(), qPrintable(result.warnings.join("; "))); + + Acp::AgentCatalog catalog; + catalog.setLayer(Acp::AgentSource::BundledSnapshot, result.agents); + QVERIFY(!catalog.launchableAgents().isEmpty()); +} + +namespace { + +Acp::AgentDefinition fakeAgentDefinition() +{ + Acp::AgentDefinition agent; + agent.id = "fake-agent"; + agent.name = "Fake Agent"; + agent.distribution.kind = Acp::AgentDistributionKind::Command; + agent.distribution.command = "/bin/true"; + return agent; +} + +class AcpBackendFixture +{ +public: + AcpBackendFixture() + { + backend.setClientFactory( + [this](const Acp::AgentDefinition &, const QString &cwd, QObject *parent) { + requestedCwd = cwd; + agent = new FakeAcpAgent(parent); + configure(agent); + return Acp::AgentProcess{ + new LLMQore::Acp::AcpClient( + agent, + {QStringLiteral("QodeAssistTest"), QStringLiteral("1.0"), QString()}, + parent), + QStringLiteral("fake")}; + }); + + QObject::connect( + &backend, + &Session::ChatBackend::sessionEvent, + &backend, + [this](const Session::SessionEvent &event) { events.append(event); }); + + backend.setStoredContentLoader([this](const QString &, const QString &storedPath) { + return storage.value(storedPath); + }); + + backend.bindAgent(fakeAgentDefinition()); + } + + void send(const QString &text) + { + Session::TurnRequest request; + request.userBlocks = {Session::TextBlock{text}}; + backend.sendTurn(request); + } + + void sendBlocks( + const QList &blocks, + const std::optional &context = std::nullopt) + { + Session::TurnRequest request; + request.userBlocks = blocks; + request.context = context; + backend.sendTurn(request); + } + + QList promptBlocksOfType(const QString &type) const + { + QList matching; + if (!agent) + return matching; + + const QList sent = agent->prompts(); + if (sent.isEmpty()) + return matching; + + for (const QJsonValue &block : sent.constLast()) { + if (block.toObject().value("type").toString() == type) + matching.append(block.toObject()); + } + return matching; + } + + template + QList eventsOfType() const + { + QList result; + for (const Session::SessionEvent &event : events) { + if (const auto *typed = std::get_if(&event)) + result.append(*typed); + } + return result; + } + + std::function configure = [](FakeAcpAgent *) {}; + QHash storage; + FakeAcpAgent *agent = nullptr; + QString requestedCwd; + QList events; + Acp::AcpChatBackend backend; +}; + +} // namespace + +void QodeAssistTest::testAcpBackendStreamsTextAndThinking() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setThoughtChunks({"pondering"}); + agent->setReplyChunks({"Hello", " world"}); + }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.eventsOfType().size(), 0); + + const auto deltas = fixture.eventsOfType(); + QCOMPARE(deltas.size(), 2); + QCOMPARE(deltas.at(0).text, QString("Hello")); + QCOMPARE(deltas.at(1).text, QString(" world")); + + const auto thinking = fixture.eventsOfType(); + QCOMPARE(thinking.size(), 1); + QCOMPARE(thinking.at(0).text, QString("pondering")); + + const QString turnId = fixture.eventsOfType().at(0).turnId; + QVERIFY(!turnId.isEmpty()); + QCOMPARE(deltas.at(0).turnId, turnId); + QCOMPARE(fixture.eventsOfType().at(0).turnId, turnId); + + QCOMPARE(fixture.agent->prompts().size(), 1); + QCOMPARE(fixture.agent->prompts().at(0).size(), 1); + QCOMPARE( + fixture.agent->prompts().at(0).at(0).toObject().value("text").toString(), QString("hi")); +} + +void QodeAssistTest::testAcpBackendStartsSessionInWorkingDirectory() +{ + AcpBackendFixture fixture; + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.requestedCwd, Acp::agentWorkingDirectory()); + QCOMPARE(fixture.agent->newSessionCwd(), Acp::agentWorkingDirectory()); + QVERIFY(!fixture.agent->newSessionCwd().isEmpty()); + QCOMPARE(fixture.agent->clientInfo().value("name").toString(), QString("QodeAssistTest")); +} + +void QodeAssistTest::testAcpBackendAuthenticatesWhenTheAgentAsksForIt() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setRequireAuthentication(true); }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.eventsOfType().size(), 0); + QCOMPARE(fixture.agent->authMethodUsed(), QString("login")); + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 2); +} + +void QodeAssistTest::testAcpBackendReportsPromptFailure() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setPromptFailure("agent exploded"); }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.eventsOfType().size(), 0); + QVERIFY(fixture.eventsOfType().at(0).error.contains("agent exploded")); + QCOMPARE( + fixture.eventsOfType().at(0).turnId, + fixture.eventsOfType().at(0).turnId); +} + +void QodeAssistTest::testAcpBackendCancelInterruptsTheTurn() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setHangOnPrompt(true); + agent->setReplyChunks({"partial"}); + }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + fixture.backend.cancel(); + + QTRY_VERIFY(fixture.agent->wasCancelled()); + QCOMPARE(fixture.eventsOfType().size(), 0); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +void QodeAssistTest::testAcpBackendSendsAttachmentsAsEmbeddedResources() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; + fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk")); + + fixture.sendBlocks( + {Session::TextBlock{"summarise this"}, + Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto resources = fixture.promptBlocksOfType("resource"); + QCOMPARE(resources.size(), 1); + + const QJsonObject resource = resources.at(0).value("resource").toObject(); + QCOMPARE(resource.value("text").toString(), QString("remember the milk")); + QCOMPARE(resource.value("mimeType").toString(), QString("text/plain")); + QVERIFY(resource.value("uri").toString().endsWith("notes.txt")); + + QCOMPARE(fixture.promptBlocksOfType("text").size(), 1); +} + +void QodeAssistTest::testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport() +{ + AcpBackendFixture fixture; + fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk")); + + fixture.sendBlocks( + {Session::TextBlock{"summarise this"}, + Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0); + + const auto texts = fixture.promptBlocksOfType("text"); + QCOMPARE(texts.size(), 2); + + const QString inlined = texts.at(1).value("text").toString(); + QVERIFY(inlined.contains("notes.txt")); + QVERIFY(inlined.contains("remember the milk")); +} + +void QodeAssistTest::testAcpBackendReportsAttachmentsItCannotLoad() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; + + fixture.sendBlocks( + {Session::TextBlock{"summarise this"}, + Session::AttachmentBlock{"notes.txt", "missing.txt"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.promptBlocksOfType("resource").size(), 0); + + const auto texts = fixture.promptBlocksOfType("text"); + QCOMPARE(texts.size(), 2); + QVERIFY(texts.at(1).value("text").toString().contains("notes.txt")); +} + +void QodeAssistTest::testAcpBackendSendsImagesWhenTheAgentAcceptsThem() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsImages(true); }; + fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes")); + + fixture.sendBlocks( + {Session::TextBlock{"what is this"}, + Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto images = fixture.promptBlocksOfType("image"); + QCOMPARE(images.size(), 1); + QCOMPARE(images.at(0).value("mimeType").toString(), QString("image/png")); + QCOMPARE( + images.at(0).value("data").toString(), + QString::fromLatin1(QByteArray("\x89PNG-bytes").toBase64())); +} + +void QodeAssistTest::testAcpBackendNamesImagesTheAgentCannotAccept() +{ + AcpBackendFixture fixture; + fixture.storage.insert("shot_cd34.png", QByteArray("\x89PNG-bytes")); + + fixture.sendBlocks( + {Session::TextBlock{"what is this"}, + Session::ImageBlock{"shot.png", "shot_cd34.png", "image/png"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.promptBlocksOfType("image").size(), 0); + + const auto texts = fixture.promptBlocksOfType("text"); + QCOMPARE(texts.size(), 2); + QVERIFY(texts.at(1).value("text").toString().contains("shot.png")); +} + +void QodeAssistTest::testAcpBackendFencesAttachmentsThatContainFences() +{ + AcpBackendFixture fixture; + fixture.storage.insert("readme_ab12.md", QByteArray("intro\n```\ncode\n```\noutro")); + + fixture.sendBlocks( + {Session::TextBlock{"read it"}, Session::AttachmentBlock{"readme.md", "readme_ab12.md"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto texts = fixture.promptBlocksOfType("text"); + QCOMPARE(texts.size(), 2); + + const QString inlined = texts.at(1).value("text").toString(); + QVERIFY(inlined.startsWith("File: readme.md\n````\n")); + QVERIFY(inlined.endsWith("\n````")); + QVERIFY(inlined.contains("```\ncode\n```")); +} + +void QodeAssistTest::testAcpBackendTruncatesOversizedAttachments() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; + + const QByteArray huge(700 * 1024, 'x'); + fixture.storage.insert("dump_ab12.log", huge); + + fixture.sendBlocks( + {Session::TextBlock{"summarise"}, Session::AttachmentBlock{"dump.log", "dump_ab12.log"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto resources = fixture.promptBlocksOfType("resource"); + QCOMPARE(resources.size(), 1); + + const QString text = resources.at(0).value("resource").toObject().value("text").toString(); + QVERIFY(text.size() < huge.size()); + QVERIFY(text.contains("truncated by QodeAssist")); +} + +void QodeAssistTest::testAcpBackendRejectsAnEmptyTurn() +{ + AcpBackendFixture fixture; + + fixture.sendBlocks({Session::TextBlock{""}}); + + QCOMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.eventsOfType().size(), 0); + QVERIFY(fixture.agent == nullptr); +} + +void QodeAssistTest::testAcpBackendSendsAttachmentsWithoutAnyMessageText() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsEmbeddedContext(true); }; + fixture.storage.insert("notes_ab12.txt", QByteArray("remember the milk")); + + fixture.sendBlocks( + {Session::TextBlock{""}, Session::AttachmentBlock{"notes.txt", "notes_ab12.txt"}}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.eventsOfType().size(), 0); + QCOMPARE(fixture.promptBlocksOfType("resource").size(), 1); +} + +namespace { + +QJsonObject writeToolCall() +{ + return QJsonObject{ + {"toolCallId", QStringLiteral("call-1")}, + {"title", QStringLiteral("Write main.cpp")}, + {"kind", QStringLiteral("edit")}, + {"status", QStringLiteral("pending")}}; +} + +QJsonArray writePermissionOptions() +{ + return QJsonArray{ + QJsonObject{ + {"optionId", QStringLiteral("allow")}, + {"name", QStringLiteral("Allow")}, + {"kind", QStringLiteral("allow_once")}}, + QJsonObject{ + {"optionId", QStringLiteral("deny")}, + {"name", QStringLiteral("Deny")}, + {"kind", QStringLiteral("reject_once")}}}; +} + +} // namespace + +void QodeAssistTest::testAcpBackendRaisesAgentPermissionRequests() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPermissionRequest(writeToolCall(), writePermissionOptions()); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const Session::PermissionRequested request + = fixture.eventsOfType().at(0); + QCOMPARE(request.turnId, fixture.eventsOfType().at(0).turnId); + QVERIFY(!request.requestId.isEmpty()); + QCOMPARE(request.toolCallId, QString("call-1")); + QCOMPARE(request.title, QString("Write main.cpp")); + QCOMPARE(request.toolKind, QString("edit")); + QCOMPARE(request.options.size(), 2); + QCOMPARE(request.options.at(0), (Session::PermissionOption{"allow", "Allow", "allow_once"})); + QCOMPARE(request.options.at(1), (Session::PermissionOption{"deny", "Deny", "reject_once"})); + + QCOMPARE(fixture.eventsOfType().size(), 0); + QVERIFY(fixture.agent->permissionOutcomes().isEmpty()); +} + +void QodeAssistTest::testAcpBackendSendsThePermissionAnswerToTheAgent() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPermissionRequest(writeToolCall(), writePermissionOptions()); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const QString requestId = fixture.eventsOfType().at(0).requestId; + fixture.backend.respondPermission(requestId, "allow"); + + QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1); + QCOMPARE( + fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("selected")); + QCOMPARE(fixture.agent->permissionOutcomes().at(0).value("optionId").toString(), QString("allow")); + + const auto resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QCOMPARE(resolutions.at(0).requestId, requestId); + QCOMPARE(resolutions.at(0).optionId, QString("allow")); + QVERIFY(!resolutions.at(0).cancelled); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); +} + +void QodeAssistTest::testAcpBackendCancelsOutstandingPermissionRequests() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPermissionRequest(writeToolCall(), writePermissionOptions()); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const QString requestId = fixture.eventsOfType().at(0).requestId; + fixture.backend.cancel(); + + QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1); + QCOMPARE( + fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("cancelled")); + + const auto resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QCOMPARE(resolutions.at(0).requestId, requestId); + QVERIFY(resolutions.at(0).cancelled); + QVERIFY(resolutions.at(0).optionId.isEmpty()); + + QTRY_VERIFY(fixture.agent->wasCancelled()); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +namespace { + +class FakeKnowledgeService : public Acp::AgentKnowledgeService +{ +public: + QString start() override + { + ++startCount; + running = true; + return url; + } + + void stop() override + { + ++stopCount; + running = false; + } + + QString serverName() const override { return QStringLiteral("qodeassist"); } + + QString url = QStringLiteral("http://127.0.0.1:54321/mcp"); + int startCount = 0; + int stopCount = 0; + bool running = false; +}; + +} // namespace + +namespace { + +class FakeGatedTool : public ::LLMQore::BaseTool +{ +public: + FakeGatedTool(QString toolId, ::LLMQore::ToolSafety safety, QObject *parent = nullptr) + : ::LLMQore::BaseTool(parent) + , m_id(std::move(toolId)) + , m_safety(safety) + {} + + QString id() const override { return m_id; } + QString displayName() const override { return m_id; } + QString description() const override { return m_id; } + QJsonObject parametersSchema() const override { return {}; } + ::LLMQore::ToolSafety safety() const override { return m_safety; } + + QFuture executeAsync(const QJsonObject &) override + { + ++executions; + QPromise promise; + promise.start(); + promise.addResult(LLMQore::ToolResult::text(QStringLiteral("ran ") + m_id)); + promise.finish(); + return promise.future(); + } + + int executions = 0; + +private: + QString m_id; + ::LLMQore::ToolSafety m_safety; +}; + +} // namespace + +void QodeAssistTest::testAcpBackendSeedsAHandoverSummaryOnce() +{ + AcpBackendFixture fixture; + + fixture.backend.setHandoverSummary("Earlier the user asked about parsing."); + + fixture.send("carry on"); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const QList first = fixture.promptBlocksOfType("text"); + QCOMPARE(first.size(), 2); + QVERIFY(first.at(0).value("text").toString().contains("Earlier the user asked about parsing.")); + QVERIFY(first.at(0).value("text").toString().contains("summary")); + QCOMPARE(first.at(1).value("text").toString(), QString("carry on")); + + fixture.send("and now this"); + QTRY_COMPARE(fixture.eventsOfType().size(), 2); + + const QList second = fixture.promptBlocksOfType("text"); + QCOMPARE(second.size(), 1); + QCOMPARE(second.at(0).value("text").toString(), QString("and now this")); +} + +void QodeAssistTest::testAcpBackendDropsAHandoverSummaryAfterACancelledTurn() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnPrompt(true); }; + + fixture.backend.setHandoverSummary("Earlier context."); + + fixture.send("carry on"); + QTRY_COMPARE(fixture.agent->prompts().size(), 1); + + QVERIFY( + fixture.agent->prompts().at(0).at(0).toObject().value("text").toString().contains( + "Earlier context.")); + + fixture.backend.cancel(); + + fixture.configure = [](FakeAcpAgent *) {}; + fixture.send("actually this"); + + QTRY_COMPARE(fixture.agent->prompts().size(), 2); + + const QJsonArray second = fixture.agent->prompts().at(1); + QCOMPARE(second.size(), 1); + QCOMPARE(second.at(0).toObject().value("text").toString(), QString("actually this")); +} + +void QodeAssistTest::testAcpBackendDropsAHandoverSummaryWithTheConversation() +{ + AcpBackendFixture fixture; + + fixture.backend.setHandoverSummary("stale summary"); + fixture.backend.clearToolSession(QString()); + + fixture.send("fresh start"); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const QList texts = fixture.promptBlocksOfType("text"); + QCOMPARE(texts.size(), 1); + QCOMPARE(texts.at(0).value("text").toString(), QString("fresh start")); +} + +void QodeAssistTest::testToolSafetyDefaultsToMutating() +{ + FakeGatedTool undeclared("undeclared", ::LLMQore::ToolSafety::Mutating); + QCOMPARE(undeclared.safety(), ::LLMQore::ToolSafety::Mutating); + + QCOMPARE(Tools::ReadFileTool().safety(), ::LLMQore::ToolSafety::ReadOnly); + QCOMPARE(Tools::GetIssuesListTool().safety(), ::LLMQore::ToolSafety::ReadOnly); + QCOMPARE(Tools::ListOpenEditorsTool().safety(), ::LLMQore::ToolSafety::ReadOnly); + + QCOMPARE(Tools::EditFileTool().safety(), ::LLMQore::ToolSafety::Mutating); + QCOMPARE(Tools::CreateNewFileTool().safety(), ::LLMQore::ToolSafety::Mutating); + QCOMPARE(Tools::ExecuteTerminalCommandTool().safety(), ::LLMQore::ToolSafety::Mutating); + QCOMPARE(Tools::BuildProjectTool().safety(), ::LLMQore::ToolSafety::Mutating); +} + +void QodeAssistTest::testToolsManagerWithoutAGateRunsImmediately() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + manager.addTool(tool); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + + QTRY_COMPARE(tool->executions, 1); +} + +void QodeAssistTest::testToolsManagerGateCanDeclineAToolCall() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + manager.addTool(tool); + + QStringList gated; + manager.setExecutionGate( + [&gated]( + const QString &, const QString &, const QString &toolName, const QJsonObject &) { + gated.append(toolName); + QPromise promise; + promise.start(); + promise.addResult(false); + promise.finish(); + return promise.future(); + }); + + QString resultText; + QObject::connect( + &manager, + &::LLMQore::ToolsManager::toolExecutionResult, + &manager, + [&resultText](const QString &, const QString &, const QString &, const QString &result) { + resultText = result; + }); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + + QTRY_VERIFY(!resultText.isEmpty()); + + QCOMPARE(gated, QStringList{"edit_thing"}); + QCOMPARE(tool->executions, 0); + QVERIFY2(resultText.contains("declined"), qPrintable(resultText)); +} + +void QodeAssistTest::testToolsManagerGateCanAllowAToolCall() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *tool = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + manager.addTool(tool); + + manager.setExecutionGate( + [](const QString &, const QString &, const QString &, const QJsonObject &) { + QPromise promise; + promise.start(); + promise.addResult(true); + promise.finish(); + return promise.future(); + }); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + + QTRY_COMPARE(tool->executions, 1); +} + +void QodeAssistTest::testToolsManagerGateResumesTheQueueAfterADenial() +{ + ::LLMQore::ToolsManager manager(::LLMQore::ToolSchemaFormat::OpenAI); + auto *denied = new FakeGatedTool("edit_thing", ::LLMQore::ToolSafety::Mutating, &manager); + auto *allowed = new FakeGatedTool("read_thing", ::LLMQore::ToolSafety::ReadOnly, &manager); + manager.addTool(denied); + manager.addTool(allowed); + + manager.setExecutionGate( + [](const QString &, const QString &, const QString &toolName, const QJsonObject &) { + QPromise promise; + promise.start(); + promise.addResult(toolName != QLatin1String("edit_thing")); + promise.finish(); + return promise.future(); + }); + + bool finished = false; + QObject::connect( + &manager, + &::LLMQore::ToolsManager::toolExecutionComplete, + &manager, + [&finished](const QString &, const QHash &) { + finished = true; + }); + + manager.executeToolCall("req-1", "call-1", "edit_thing", QJsonObject{}); + manager.executeToolCall("req-1", "call-2", "read_thing", QJsonObject{}); + + QTRY_VERIFY(finished); + QCOMPARE(denied->executions, 0); + QCOMPARE(allowed->executions, 1); +} + +void QodeAssistTest::testKnowledgeServerExposesOnlyReadOnlyTools() +{ + const QStringList exposed = Mcp::AgentKnowledgeServer::toolIds(); + + QCOMPARE( + QSet(exposed.cbegin(), exposed.cend()), + (QSet{ + "list_open_editors", "get_editor_selection", "get_project_model", "get_issues_list"})); + + const QStringList mutating{ + Tools::EditFileTool().id(), + Tools::CreateNewFileTool().id(), + Tools::ExecuteTerminalCommandTool().id(), + Tools::BuildProjectTool().id()}; + + for (const QString &tool : mutating) + QVERIFY2(!exposed.contains(tool), qPrintable(tool)); +} + +void QodeAssistTest::testKnowledgeServerBindsLoopbackOnAnEphemeralPort() +{ + Mcp::AgentKnowledgeServer server; + + const QString url = server.start(); + QVERIFY(!url.isEmpty()); + QVERIFY(server.runningPort() != 0); + + const QUrl parsed(url); + QVERIFY(QHostAddress(parsed.host()).isLoopback()); + QCOMPARE(parsed.port(), int(server.runningPort())); + + QVERIFY2( + parsed.path().length() > QStringLiteral("/mcp/").length(), + qPrintable(parsed.path())); + + QCOMPARE(server.start(), url); + + server.stop(); + QCOMPARE(server.runningPort(), quint16(0)); + server.stop(); +} + +void QodeAssistTest::testAcpBackendOffersKnowledgeToHttpCapableAgents() +{ + AcpBackendFixture fixture; + FakeKnowledgeService knowledge; + fixture.backend.setKnowledgeService(&knowledge); + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); }; + + fixture.send("what am I looking at"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(knowledge.startCount, 1); + QVERIFY(knowledge.running); + + const QJsonArray offered = fixture.agent->offeredMcpServers(); + QCOMPARE(offered.size(), 1); + QCOMPARE(offered.at(0).toObject().value("name").toString(), QString("qodeassist")); + QCOMPARE(offered.at(0).toObject().value("type").toString(), QString("http")); + QCOMPARE(offered.at(0).toObject().value("url").toString(), knowledge.url); +} + +void QodeAssistTest::testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp() +{ + AcpBackendFixture fixture; + FakeKnowledgeService knowledge; + fixture.backend.setKnowledgeService(&knowledge); + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(false); }; + + fixture.send("what am I looking at"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(knowledge.startCount, 0); + QVERIFY(!knowledge.running); + QVERIFY(fixture.agent->offeredMcpServers().isEmpty()); +} + +void QodeAssistTest::testAcpBackendStopsTheKnowledgeServerWithTheSession() +{ + AcpBackendFixture fixture; + FakeKnowledgeService knowledge; + fixture.backend.setKnowledgeService(&knowledge); + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); }; + + fixture.send("hello"); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QVERIFY(knowledge.running); + + fixture.backend.clearToolSession(QString()); + + QCOMPARE(knowledge.stopCount, 1); + QVERIFY(!knowledge.running); +} + +void QodeAssistTest::testAcpBackendSurvivesAKnowledgeServerThatWillNotStart() +{ + AcpBackendFixture fixture; + FakeKnowledgeService knowledge; + knowledge.url.clear(); + fixture.backend.setKnowledgeService(&knowledge); + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsHttpMcp(true); }; + + fixture.send("hello"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(knowledge.startCount, 1); + QVERIFY(fixture.agent->offeredMcpServers().isEmpty()); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +void QodeAssistTest::testAcpBackendResumesAPersistedSession() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); }; + + fixture.backend.resumeSession("previous-session"); + QVERIFY(fixture.backend.isResumePending()); + + fixture.send("carry on"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.agent->loadedSessionId(), QString("previous-session")); + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0); + QCOMPARE(fixture.backend.acpSessionId(), QString("previous-session")); + QVERIFY(!fixture.backend.isResumePending()); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +void QodeAssistTest::testAcpBackendReportsAnUnresumableSession() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setSupportsLoadSession(true); + agent->setLoadSessionError("session expired"); + }; + + QStringList unavailable; + QObject::connect( + &fixture.backend, + &Acp::AcpChatBackend::agentSessionUnavailable, + &fixture.backend, + [&unavailable](const QString &reason) { unavailable.append(reason); }); + + fixture.backend.resumeSession("previous-session"); + fixture.send("carry on"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(unavailable.size(), 1); + QVERIFY(unavailable.at(0).contains("session expired")); + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0); + QVERIFY(fixture.backend.acpSessionId().isEmpty()); + QVERIFY(!fixture.backend.isResumePending()); + QVERIFY(fixture.eventsOfType().at(0).error.contains("read-only")); +} + +void QodeAssistTest::testAcpBackendRefusesToResumeWithoutAgentSupport() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(false); }; + + QStringList unavailable; + QObject::connect( + &fixture.backend, + &Acp::AcpChatBackend::agentSessionUnavailable, + &fixture.backend, + [&unavailable](const QString &reason) { unavailable.append(reason); }); + + fixture.backend.resumeSession("previous-session"); + fixture.send("carry on"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(unavailable.size(), 1); + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/load")), 0); + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 0); + QVERIFY(fixture.backend.acpSessionId().isEmpty()); +} + +void QodeAssistTest::testAcpBackendStartsFreshAfterAFailedResume() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setSupportsLoadSession(true); + agent->setLoadSessionError("session expired"); + }; + + fixture.backend.resumeSession("previous-session"); + fixture.send("carry on"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + fixture.backend.startFreshSession(); + fixture.send("start over"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1); + QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session")); + QCOMPARE(fixture.agent->prompts().size(), 1); + QCOMPARE( + fixture.agent->prompts().at(0).at(0).toObject().value("text").toString(), + QString("start over")); +} + +void QodeAssistTest::testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); }; + + QVERIFY(fixture.backend.bindingSessionId().isEmpty()); + + fixture.backend.resumeSession("sess-from-file"); + QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file")); + QVERIFY(fixture.backend.acpSessionId().isEmpty()); + + fixture.send("carry on"); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + QCOMPARE(fixture.backend.acpSessionId(), QString("sess-from-file")); + QCOMPARE(fixture.backend.bindingSessionId(), QString("sess-from-file")); +} + +void QodeAssistTest::testAcpBackendCarriesTheLiveSessionIntoAResume() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setSupportsLoadSession(true); }; + + fixture.send("hello"); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.backend.acpSessionId(), QString("fake-session")); + + fixture.backend.setChatFilePath("/somewhere/else.json"); + QCOMPARE(fixture.backend.bindingSessionId(), QString("fake-session")); + + fixture.backend.clearToolSession(QString()); + QVERIFY(fixture.backend.bindingSessionId().isEmpty()); +} + +void QodeAssistTest::testAcpBackendEstablishesTheSessionOnlyOnce() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { agent->setHangOnNewSession(true); }; + + fixture.send("first"); + QTRY_COMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1); + + fixture.backend.cancel(); + fixture.send("second"); + QTest::qWait(50); + + QCOMPARE(fixture.agent->methods().count(QStringLiteral("session/new")), 1); + QCOMPARE(fixture.agent->prompts().size(), 0); +} + +void QodeAssistTest::testAgentBindingRejectsMalformedRecords() +{ + const auto rejected = [](const QJsonValue &value) { + QString error; + const Acp::AgentBinding binding = Acp::AgentBinding::fromJson(value, &error); + return binding.isEmpty() && !error.isEmpty(); + }; + + QVERIFY(rejected(QJsonValue(QStringLiteral("claude")))); + QVERIFY(rejected(QJsonValue(42))); + QVERIFY(rejected(QJsonObject{{"agentId", 7}})); + QVERIFY(rejected(QJsonObject{{"agentId", "claude"}, {"sessionId", QJsonArray{}}})); + QVERIFY(rejected(QJsonObject{{"sessionId", "orphan"}})); + QVERIFY(rejected(QJsonObject{{"agentId", QString(4096, QChar('a'))}})); + QVERIFY(rejected(QJsonObject{{"agentId", QStringLiteral("clau‮de")}})); + + QString error; + QVERIFY(Acp::AgentBinding::fromJson(QJsonValue(), &error).isEmpty()); + QVERIFY(error.isEmpty()); + + const Acp::AgentBinding good = Acp::AgentBinding::fromJson( + QJsonObject{{"agentId", "claude"}, {"sessionId", "sess-1"}}, &error); + QCOMPARE(good, (Acp::AgentBinding{"claude", "sess-1"})); + QVERIFY(error.isEmpty()); +} + +void QodeAssistTest::testMalformedAgentBindingLeavesTheChatUnbound() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString filePath = dir.filePath("broken_agent.json"); + + Session::Message user; + user.role = Session::MessageRole::User; + user.blocks = {Session::TextBlock{"hello"}}; + + Session::ConversationHistory history; + history.append(user); + + QVERIFY(Chat::ChatFileStore::saveToFile(history, Acp::AgentBinding{}, filePath).success); + + QFile file(filePath); + QVERIFY(file.open(QIODevice::ReadWrite)); + QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + root["agent"] = QStringLiteral("claude"); + file.resize(0); + file.write(QJsonDocument(root).toJson()); + file.close(); + + Session::ConversationHistory reloaded; + Acp::AgentBinding binding; + const Chat::SerializationResult result + = Chat::ChatFileStore::loadFromFile(reloaded, binding, filePath); + + QVERIFY(result.success); + QVERIFY(binding.isEmpty()); + QVERIFY(!result.warningMessage.isEmpty()); +} + +void QodeAssistTest::testAgentBindingRoundTripsThroughTheChatFile() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString filePath = dir.filePath("agent_chat.json"); + + Session::Message user; + user.role = Session::MessageRole::User; + user.blocks = {Session::TextBlock{"hello agent"}}; + + Session::ConversationHistory history; + history.append(user); + + const Acp::AgentBinding binding{"claude-code-acp", "sess-42"}; + QVERIFY(Chat::ChatFileStore::saveToFile(history, binding, filePath).success); + + Session::ConversationHistory reloaded; + Acp::AgentBinding reloadedBinding; + QVERIFY(Chat::ChatFileStore::loadFromFile(reloaded, reloadedBinding, filePath).success); + + QCOMPARE(reloaded, history); + QCOMPARE(reloadedBinding, binding); +} + +void QodeAssistTest::testChatFileWithoutAnAgentLoadsUnbound() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString filePath = dir.filePath("llm_chat.json"); + + Session::Message user; + user.role = Session::MessageRole::User; + user.blocks = {Session::TextBlock{"hello"}}; + + Session::ConversationHistory history; + history.append(user); + + QVERIFY(Chat::ChatFileStore::saveToFile(history, Acp::AgentBinding{}, filePath).success); + + QFile file(filePath); + QVERIFY(file.open(QIODevice::ReadOnly)); + const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); + QVERIFY(!root.contains("agent")); + file.close(); + + Session::ConversationHistory reloaded; + Acp::AgentBinding reloadedBinding; + QVERIFY(Chat::ChatFileStore::loadFromFile(reloaded, reloadedBinding, filePath).success); + QVERIFY(reloadedBinding.isEmpty()); +} + +void QodeAssistTest::testAcpBackendMapsToolCallLifecycle() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setToolCallUpdates( + {QJsonObject{ + {"toolCallId", QStringLiteral("call-1")}, + {"title", QStringLiteral("Edit main.cpp")}, + {"kind", QStringLiteral("edit")}, + {"status", QStringLiteral("pending")}}, + QJsonObject{ + {"toolCallId", QStringLiteral("call-1")}, + {"status", QStringLiteral("completed")}, + {"locations", + QJsonArray{QJsonObject{{"path", QStringLiteral("/p/main.cpp")}, {"line", 12}}}}, + {"content", + QJsonArray{ + QJsonObject{ + {"type", QStringLiteral("diff")}, + {"path", QStringLiteral("/p/main.cpp")}, + {"oldText", QStringLiteral("int a;")}, + {"newText", QStringLiteral("int b;")}}}}}}); + }; + + fixture.send("edit it"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto updates = fixture.eventsOfType(); + QCOMPARE(updates.size(), 2); + + QCOMPARE(updates.at(0).toolId, QString("call-1")); + QCOMPARE(updates.at(0).name, QString("Edit main.cpp")); + QCOMPARE(updates.at(0).kind, QString("edit")); + QCOMPARE(updates.at(0).status, QString("pending")); + QVERIFY(updates.at(0).details.isEmpty()); + QVERIFY(updates.at(0).fromAgent); + QVERIFY(updates.at(1).fromAgent); + + QCOMPARE(updates.at(1).status, QString("completed")); + QCOMPARE(updates.at(1).name, QString("Edit main.cpp")); + + const QJsonArray locations = updates.at(1).details.value("locations").toArray(); + QCOMPARE(locations.size(), 1); + QCOMPARE(locations.at(0).toObject().value("path").toString(), QString("/p/main.cpp")); + QCOMPARE(locations.at(0).toObject().value("line").toInt(), 12); + + const QJsonArray diffs = updates.at(1).details.value("diffs").toArray(); + QCOMPARE(diffs.size(), 1); + QCOMPARE(diffs.at(0).toObject().value("oldText").toString(), QString("int a;")); + QCOMPARE(diffs.at(0).toObject().value("newText").toString(), QString("int b;")); +} + +void QodeAssistTest::testAcpBackendMapsPlanUpdates() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPlanUpdates( + {QJsonArray{ + QJsonObject{ + {"content", QStringLiteral("Read the file")}, + {"priority", QStringLiteral("high")}, + {"status", QStringLiteral("in_progress")}}, + QJsonObject{ + {"content", QStringLiteral("Apply the fix")}, + {"priority", QStringLiteral("medium")}, + {"status", QStringLiteral("pending")}}}, + QJsonArray{ + QJsonObject{ + {"content", QStringLiteral("Read the file")}, + {"priority", QStringLiteral("high")}, + {"status", QStringLiteral("completed")}}, + QJsonObject{ + {"content", QStringLiteral("Apply the fix")}, + {"priority", QStringLiteral("medium")}, + {"status", QStringLiteral("in_progress")}}}}); + }; + + fixture.send("fix it"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto plans = fixture.eventsOfType(); + QCOMPARE(plans.size(), 2); + QCOMPARE(plans.at(0).entries.size(), 2); + QCOMPARE( + plans.at(0).entries.at(0), + (Session::PlanEntry{"Read the file", "high", "in_progress"})); + QCOMPARE( + plans.at(1).entries.at(0), (Session::PlanEntry{"Read the file", "high", "completed"})); +} + +void QodeAssistTest::testAcpBackendReportsUsageFromThePromptResult() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPromptUsage( + QJsonObject{ + {"cachedReadTokens", 23119}, + {"cachedWriteTokens", 12271}, + {"inputTokens", 2}, + {"outputTokens", 13}, + {"totalTokens", 35405}}); + }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto usage = fixture.eventsOfType(); + QCOMPARE(usage.size(), 1); + QCOMPARE(usage.at(0).usage.promptTokens, 2); + QCOMPARE(usage.at(0).usage.completionTokens, 13); + QCOMPARE(usage.at(0).usage.cachedPromptTokens, 23119); + QCOMPARE(usage.at(0).usage.reasoningTokens, 0); +} + +void QodeAssistTest::testAcpBackendIgnoresTheCumulativeContextGauge() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setContextGauge( + QJsonObject{ + {"size", 967000}, + {"used", 35395}, + {"cost", QJsonObject{{"amount", 0.0807}, {"currency", QStringLiteral("USD")}}}}); + }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +void QodeAssistTest::testAcpBackendForwardsTheAgentSuggestedTitle() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setSuggestedTitle(" Refactor\nthe parser "); + }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE( + fixture.eventsOfType().at(0).title, + QString("Refactor the parser")); +} + +void QodeAssistTest::testAcpBackendDeclinesAmbiguousPermissionOptions() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPermissionRequest( + writeToolCall(), + QJsonArray{ + QJsonObject{ + {"optionId", QStringLiteral("x")}, + {"name", QStringLiteral("Yes, and don't ask again")}, + {"kind", QStringLiteral("allow_always")}}, + QJsonObject{ + {"optionId", QStringLiteral("x")}, + {"name", QStringLiteral("No")}, + {"kind", QStringLiteral("reject_once")}}}); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto requests = fixture.eventsOfType(); + QCOMPARE(requests.size(), 1); + QVERIFY(requests.at(0).options.isEmpty()); + + QVERIFY(fixture.eventsOfType().at(0).cancelled); + QTRY_COMPARE(fixture.agent->permissionOutcomes().size(), 1); + QCOMPARE( + fixture.agent->permissionOutcomes().at(0).value("outcome").toString(), QString("cancelled")); +} + +void QodeAssistTest::testAcpBackendClampsOversizedPermissionText() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + QJsonObject toolCall = writeToolCall(); + toolCall["title"] = QString(5000, QChar('A')); + agent->setPermissionRequest(toolCall, writePermissionOptions()); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const Session::PermissionRequested request + = fixture.eventsOfType().at(0); + QVERIFY(request.title.size() < 5000); + QVERIFY(request.title.endsWith(QChar(0x2026))); + QCOMPARE(request.options.size(), 2); +} + +void QodeAssistTest::testAcpBackendCancelsPermissionsWhenTheTurnCompletes() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPermissionRequest(writeToolCall(), writePermissionOptions()); + agent->setFinishPromptWithoutWaiting(true); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + const auto resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QVERIFY(resolutions.at(0).cancelled); + + const QString requestId = fixture.eventsOfType().at(0).requestId; + QVERIFY(!fixture.backend.respondPermission(requestId, "allow")); +} + +void QodeAssistTest::testAcpBackendCancelsPermissionRequestsWhenTheTurnFails() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setPermissionRequest(writeToolCall(), writePermissionOptions()); + }; + + fixture.send("edit main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + + fixture.backend.clearToolSession(QString()); + + QCOMPARE(fixture.eventsOfType().size(), 1); + QVERIFY(fixture.eventsOfType().at(0).cancelled); +} + +namespace { + +class LlmBackendFixture +{ +public: + LlmBackendFixture() + : backend(&promptProvider) + { + backend.setProviderResolver([this](const QString &) { return &provider; }); + + QObject::connect( + &backend, + &Session::ChatBackend::sessionEvent, + &backend, + [this](const Session::SessionEvent &event) { events.append(event); }); + + Session::Message user; + user.role = Session::MessageRole::User; + user.id = "u1"; + user.blocks = {Session::TextBlock{"hello"}}; + history.append(user); + } + + QString send() + { + Session::TurnRequest request; + request.userBlocks = {Session::TextBlock{"hello"}}; + request.history = &history; + backend.sendTurn(request); + + const auto started = eventsOfType(); + return started.isEmpty() ? QString() : started.constLast().turnId; + } + + template + QList eventsOfType() const + { + QList result; + for (const Session::SessionEvent &event : events) { + if (const auto *typed = std::get_if(&event)) + result.append(*typed); + } + return result; + } + + FakeChatPromptProvider promptProvider; + FakeLlmProvider provider; + Session::ConversationHistory history; + QList events; + Chat::LlmChatBackend backend; +}; + +} // namespace + +void QodeAssistTest::testTurnLedgerTracksTheActiveTurn() +{ + Session::TurnLedger ledger; + + QVERIFY(!ledger.hasActiveTurn()); + QVERIFY(!ledger.isActiveTurn(QString())); + QVERIFY(!ledger.isActiveTurn("t1")); + + QCOMPARE(ledger.beginTurn("t1"), QString("t1")); + QVERIFY(ledger.hasActiveTurn()); + QVERIFY(ledger.isActiveTurn("t1")); + QVERIFY(!ledger.isActiveTurn("t2")); + QVERIFY(!ledger.isActiveTurn(QString())); + + ledger.endTurn(); + QVERIFY(!ledger.hasActiveTurn()); + QVERIFY(!ledger.isActiveTurn("t1")); +} + +void QodeAssistTest::testTurnLedgerDrainsPermissionsOnTurnEnd() +{ + Session::TurnLedger ledger; + ledger.beginTurn("t1"); + + QStringList resolved; + int cancelled = 0; + + const QString first = ledger.registerPermission( + [&resolved](const QString &optionId) { resolved.append(optionId); }, + [&cancelled] { ++cancelled; }); + const QString second = ledger.registerPermission( + [&resolved](const QString &optionId) { resolved.append(optionId); }, + [&cancelled] { ++cancelled; }); + + QVERIFY(first.startsWith("perm-")); + QVERIFY(second.startsWith("perm-")); + QVERIFY(first != second); + QCOMPARE(ledger.pendingPermissionCount(), 2); + + QVERIFY(ledger.resolvePermission(first, "allow_once")); + QCOMPARE(resolved, QStringList{"allow_once"}); + QVERIFY(!ledger.resolvePermission(first, "allow_once")); + QVERIFY(!ledger.hasPendingPermission(first)); + QVERIFY(ledger.hasPendingPermission(second)); + + const QStringList drained = ledger.endTurn(); + QCOMPARE(drained, QStringList{second}); + QCOMPARE(cancelled, 1); + QCOMPARE(ledger.pendingPermissionCount(), 0); + QVERIFY(!ledger.resolvePermission(second, "allow_once")); + QCOMPARE(resolved, QStringList{"allow_once"}); +} + +void QodeAssistTest::testLlmBackendStreamsThroughAFakeClient() +{ + LlmBackendFixture fixture; + + const QString turnId = fixture.send(); + QCOMPARE(turnId, QString("fake-req-1")); + QVERIFY(fixture.provider.fakeClient()->lastPayload.contains("model")); + QVERIFY(fixture.provider.fakeClient()->lastPayload.contains("stream")); + + emit fixture.provider.fakeClient()->chunkReceived(turnId, "hel"); + emit fixture.provider.fakeClient()->chunkReceived(turnId, "lo"); + emit fixture.provider.fakeClient()->requestCompleted(turnId, "hello"); + + const auto deltas = fixture.eventsOfType(); + QCOMPARE(deltas.size(), 2); + QCOMPARE(deltas.at(0).text, QString("hel")); + QCOMPARE(deltas.at(1).text, QString("lo")); + QCOMPARE(deltas.at(0).turnId, turnId); + + QCOMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.eventsOfType().at(0).turnId, turnId); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +void QodeAssistTest::testRenderHistoryKeepsToolExchangePairs() +{ + LlmBackendFixture fixture; + + Session::Message assistant; + assistant.role = Session::MessageRole::Assistant; + assistant.id = "r0"; + assistant.blocks + = {Session::ToolCallBlock{ + "t1", + "read_file", + QJsonObject{{"path", "main.cpp"}}, + "int main() {}", + {}, + "completed"}, + Session::ToolCallBlock{ + "call-9", + "Edit main.cpp", + QJsonObject{}, + "done", + "edit", + "completed", + QJsonObject{}, + true}, + Session::TextBlock{"the answer"}}; + fixture.history.append(assistant); + + fixture.send(); + + QVERIFY(fixture.provider.lastContext.history.has_value()); + const QVector &messages = *fixture.provider.lastContext.history; + + QCOMPARE(messages.size(), 4); + QCOMPARE(messages.at(0).role, QString("user")); + QCOMPARE(messages.at(1).role, QString("assistant")); + QCOMPARE(messages.at(1).toolCalls.size(), 1); + QCOMPARE(messages.at(1).toolCalls.at(0).id, QString("t1")); + QCOMPARE(messages.at(1).toolCalls.at(0).name, QString("read_file")); + QCOMPARE( + messages.at(1).toolCalls.at(0).arguments, (QJsonObject{{"path", "main.cpp"}})); + QCOMPARE(messages.at(2).role, QString("tool")); + QCOMPARE(messages.at(2).toolCallId, QString("t1")); + QCOMPARE(messages.at(2).content, QString("int main() {}")); + QCOMPARE(messages.at(3).role, QString("assistant")); + QCOMPARE(messages.at(3).content, QString("the answer")); +} + +void QodeAssistTest::testLlmBackendIgnoresEventsFromOtherRequests() +{ + LlmBackendFixture fixture; + + const QString turnId = fixture.send(); + QVERIFY(!turnId.isEmpty()); + + emit fixture.provider.fakeClient()->chunkReceived("stale-req", "ignored"); + QCOMPARE(fixture.eventsOfType().size(), 0); + + fixture.backend.cancel(); + + emit fixture.provider.fakeClient()->chunkReceived(turnId, "late"); + QCOMPARE(fixture.eventsOfType().size(), 0); + QCOMPARE(fixture.eventsOfType().size(), 0); +} + +void QodeAssistTest::testLlmBackendPermissionRoundTrip() +{ + LlmBackendFixture fixture; + auto *tool = new FakeMutatingTool(&fixture.backend); + fixture.provider.toolsManager()->addTool(tool); + + const QString turnId = fixture.send(); + + fixture.provider.toolsManager()->executeToolCall(turnId, "t1", "fake_write", {}); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + const Session::PermissionRequested request + = fixture.eventsOfType().at(0); + QCOMPARE(request.turnId, turnId); + QVERIFY(request.requestId.startsWith("perm-")); + QCOMPARE(request.toolKind, QString("fake_write")); + QCOMPARE(request.options.size(), 3); + + QVERIFY(fixture.backend.respondPermission(request.requestId, "allow_once")); + QTRY_COMPARE(tool->executions, 1); + + auto resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QCOMPARE(resolutions.at(0).requestId, request.requestId); + QCOMPARE(resolutions.at(0).optionId, QString("allow_once")); + QVERIFY(!resolutions.at(0).cancelled); + + fixture.provider.toolsManager()->executeToolCall(turnId, "t2", "fake_write", {}); + QTRY_COMPARE(fixture.eventsOfType().size(), 2); + const QString second = fixture.eventsOfType().at(1).requestId; + QVERIFY(second != request.requestId); + + QVERIFY(fixture.backend.respondPermission(second, "reject_once")); + QTRY_COMPARE(fixture.eventsOfType().size(), 2); + QCOMPARE(tool->executions, 1); + + QTRY_VERIFY( + !fixture.eventsOfType().isEmpty() + && fixture.eventsOfType().constLast().toolId == QString("t2")); + const Session::ToolCallUpdated declined + = fixture.eventsOfType().constLast(); + QCOMPARE(declined.toolId, QString("t2")); + QCOMPARE(declined.status, QString("failed")); + QVERIFY(declined.result.startsWith("Error: ")); +} + +void QodeAssistTest::testLlmBackendDrainsPermissionsWhenTheTurnEnds() +{ + LlmBackendFixture fixture; + auto *tool = new FakeMutatingTool(&fixture.backend); + fixture.provider.toolsManager()->addTool(tool); + + QString turnId = fixture.send(); + fixture.provider.toolsManager()->executeToolCall(turnId, "t1", "fake_write", {}); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QString requestId = fixture.eventsOfType().at(0).requestId; + + emit fixture.provider.fakeClient()->requestCompleted(turnId, "done"); + + auto resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QCOMPARE(resolutions.at(0).requestId, requestId); + QVERIFY(resolutions.at(0).cancelled); + QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once")); + QCOMPARE(tool->executions, 0); + + fixture.events.clear(); + turnId = fixture.send(); + fixture.provider.toolsManager()->executeToolCall(turnId, "t2", "fake_write", {}); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + requestId = fixture.eventsOfType().at(0).requestId; + + emit fixture.provider.fakeClient()->requestFailed(turnId, "boom"); + + resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QVERIFY(resolutions.at(0).cancelled); + QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once")); + QCOMPARE(fixture.eventsOfType().size(), 1); + + fixture.events.clear(); + turnId = fixture.send(); + fixture.provider.toolsManager()->executeToolCall(turnId, "t3", "fake_write", {}); + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + requestId = fixture.eventsOfType().at(0).requestId; + + fixture.backend.cancel(); + + resolutions = fixture.eventsOfType(); + QCOMPARE(resolutions.size(), 1); + QVERIFY(resolutions.at(0).cancelled); + QVERIFY(!fixture.backend.respondPermission(requestId, "allow_once")); + QCOMPARE(tool->executions, 0); +} + +namespace { + +Acp::AgentDefinition launchableAgent(const QString &id) +{ + Acp::AgentDefinition agent; + agent.id = id; + agent.name = id; + agent.distribution.kind = Acp::AgentDistributionKind::Command; + agent.distribution.command = "/bin/true"; + return agent; +} + +class FakeConversationOps : public Chat::IConversationPort +{ +public: + QString boundAgentId() const override { return boundId; } + bool conversationStarted() const override { return started; } + bool transcriptEmpty() const override { return transcriptIsEmpty; } + Acp::AgentBinding agentBinding() const override { return liveBinding; } + + void bindAgent(const Acp::AgentDefinition &agent) override + { + boundId = agent.id; + calls.append("bindAgent:" + agent.id); + } + + void bindLlm() override + { + boundId.clear(); + calls.append("bindLlm"); + } + + void clearConversation() override + { + started = false; + calls.append("clear"); + } + + void resumeAgentSession(const QString &sessionId) override + { + calls.append("resume:" + sessionId); + } + + void startFreshAgentSession() override { calls.append("fresh"); } + + void startFreshAgentSession(const QString &handoverSummary) override + { + calls.append("freshWithSummary:" + handoverSummary); + } + + void releaseAgentSession() override { calls.append("release"); } + + QString boundId; + bool started = false; + bool transcriptIsEmpty = true; + Acp::AgentBinding liveBinding; + QStringList calls; +}; + +class FakeAgentCatalog : public Chat::IAgentCatalogPort +{ +public: + std::optional agentById(const QString &agentId) const override + { + const auto it = agents.constFind(agentId); + if (it == agents.constEnd()) + return std::nullopt; + return *it; + } + + QHash agents; +}; + +class FakeCompressionPort : public Chat::ICompressionPort +{ +public: + QString compressionConfigurationIssue() const override { return configurationIssue; } + bool isCompressionRunning() const override { return running; } + void startTranscriptSummary() override { ++summaryStarts; } + void startCompression() override { ++compressionStarts; } + + QString configurationIssue; + bool running = false; + int summaryStarts = 0; + int compressionStarts = 0; +}; + +class FakeSendPort : public Chat::ISendPort +{ +public: + bool autoCompressEnabled() const override { return autoCompress; } + int autoCompressThreshold() const override { return threshold; } + int estimatedNextTokens() const override { return tokens; } + + bool prepareChatFileForCompression(const QString &, const QStringList &) override + { + return chatFileAvailable; + } + + void dispatch(const QString &message, const QStringList &) override + { + dispatched.append(message); + } + + bool autoCompress = false; + int threshold = 1000; + int tokens = 0; + bool chatFileAvailable = true; + QStringList dispatched; +}; + +class CoordinatorFixture +{ +public: + CoordinatorFixture() + : coordinator({&ops, &catalog, &compression, &send}) + { + QObject::connect( + &coordinator, + &Chat::ConversationCoordinator::errorSurfaced, + &coordinator, + [this](const QString &message) { errors.append(message); }); + QObject::connect( + &coordinator, + &Chat::ConversationCoordinator::switchConfirmationNeeded, + &coordinator, + [this](const QString &targetName) { confirmations.append(targetName); }); + } + + FakeConversationOps ops; + FakeAgentCatalog catalog; + FakeCompressionPort compression; + FakeSendPort send; + QStringList errors; + QStringList confirmations; + Chat::ConversationCoordinator coordinator; +}; + +} // namespace + +void QodeAssistTest::testCoordinatorSwitchConfirmAndCancelRoundTrip() +{ + CoordinatorFixture fixture; + + fixture.coordinator.chooseAgent(launchableAgent("agent-a")); + QVERIFY(!fixture.coordinator.switchPending()); + QCOMPARE(fixture.ops.calls, QStringList{"bindAgent:agent-a"}); + + fixture.ops.started = true; + fixture.ops.calls.clear(); + + fixture.coordinator.chooseLlm(); + QVERIFY(fixture.coordinator.switchPending()); + QCOMPARE(fixture.confirmations.size(), 1); + QVERIFY(fixture.ops.calls.isEmpty()); + + fixture.coordinator.cancelSwitch(); + QVERIFY(!fixture.coordinator.switchPending()); + QVERIFY(fixture.ops.calls.isEmpty()); + QCOMPARE(fixture.ops.boundId, QString("agent-a")); + + fixture.coordinator.chooseAgent(launchableAgent("agent-b")); + QVERIFY(fixture.coordinator.switchPending()); + QCOMPARE(fixture.confirmations.size(), 2); + + fixture.coordinator.confirmSwitch(); + QVERIFY(!fixture.coordinator.switchPending()); + QCOMPARE(fixture.ops.calls, (QStringList{"clear", "bindAgent:agent-b"})); + QVERIFY(!fixture.ops.started); + + fixture.coordinator.confirmSwitch(); + QCOMPARE(fixture.ops.calls, (QStringList{"clear", "bindAgent:agent-b"})); +} + +void QodeAssistTest::testCoordinatorQuarantinesAndRestoresAgentBindings() +{ + CoordinatorFixture fixture; + const Acp::AgentBinding binding{"ghost-agent", "sess-1"}; + + fixture.coordinator.restoreAgentBinding(binding); + + QVERIFY(fixture.coordinator.readOnly()); + QVERIFY(!fixture.coordinator.canStartFreshSession()); + QCOMPARE(fixture.coordinator.bindingForSave(), binding); + QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindLlm"})); + + fixture.ops.calls.clear(); + fixture.catalog.agents.insert("ghost-agent", launchableAgent("ghost-agent")); + + fixture.coordinator.restoreAgentBinding(binding); + + QVERIFY(!fixture.coordinator.readOnly()); + QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindAgent:ghost-agent", "resume:sess-1"})); + fixture.ops.liveBinding = Acp::AgentBinding{"ghost-agent", "sess-1"}; + QCOMPARE(fixture.coordinator.bindingForSave(), fixture.ops.liveBinding); + + fixture.ops.calls.clear(); + fixture.ops.started = true; + + fixture.coordinator.restoreAgentBinding(Acp::AgentBinding{"ghost-agent", QString()}); + + QVERIFY(fixture.coordinator.readOnly()); + QVERIFY(fixture.coordinator.canStartFreshSession()); + QCOMPARE(fixture.ops.calls, (QStringList{"release", "bindAgent:ghost-agent"})); + + fixture.coordinator.startFreshSession(); + QVERIFY(!fixture.coordinator.readOnly()); + QVERIFY(fixture.ops.calls.contains("fresh")); +} + +void QodeAssistTest::testCoordinatorRefusesIntentsWhileReadOnly() +{ + CoordinatorFixture fixture; + fixture.coordinator.agentSessionUnavailable("gone"); + QVERIFY(fixture.coordinator.readOnly()); + + fixture.coordinator.requestSend("hi", {}); + QCOMPARE(fixture.send.dispatched.size(), 0); + QCOMPARE(fixture.errors.size(), 1); + + QVERIFY(fixture.coordinator.refuseWhileReadOnly()); + QCOMPARE(fixture.errors.size(), 2); + + fixture.coordinator.compressionSettled(); + QCOMPARE(fixture.send.dispatched.size(), 0); + + fixture.coordinator.startFreshSession(); + QVERIFY(!fixture.coordinator.readOnly()); + QVERIFY(!fixture.coordinator.refuseWhileReadOnly()); + + fixture.coordinator.requestSend("hi again", {}); + QCOMPARE(fixture.send.dispatched, QStringList{"hi again"}); +} + +void QodeAssistTest::testCoordinatorDefersTheSendForAutoCompress() +{ + CoordinatorFixture fixture; + fixture.send.autoCompress = true; + fixture.send.threshold = 100; + fixture.send.tokens = 150; + + fixture.coordinator.requestSend("big message", {}); + QCOMPARE(fixture.send.dispatched.size(), 0); + QCOMPARE(fixture.compression.compressionStarts, 1); + QVERIFY(fixture.coordinator.hasDeferredSend()); + + fixture.coordinator.requestSend("while compressing", {}); + QCOMPARE(fixture.send.dispatched, QStringList{"while compressing"}); + QCOMPARE(fixture.compression.compressionStarts, 1); + + fixture.coordinator.compressionSettled(); + QCOMPARE(fixture.send.dispatched, (QStringList{"while compressing", "big message"})); + QVERIFY(!fixture.coordinator.hasDeferredSend()); + + fixture.send.tokens = 50; + fixture.coordinator.requestSend("small", {}); + QCOMPARE(fixture.send.dispatched.size(), 3); + QCOMPARE(fixture.compression.compressionStarts, 1); + + fixture.send.tokens = 150; + fixture.ops.boundId = "agent-a"; + fixture.coordinator.requestSend("agent bound", {}); + QCOMPARE(fixture.send.dispatched.size(), 4); + QCOMPARE(fixture.compression.compressionStarts, 1); + + fixture.ops.boundId.clear(); + fixture.send.chatFileAvailable = false; + fixture.coordinator.requestSend("no chat file", {}); + QCOMPARE(fixture.send.dispatched.size(), 5); + QCOMPARE(fixture.compression.compressionStarts, 1); +} + +void QodeAssistTest::testCoordinatorGatesTheSummaryHandover() +{ + CoordinatorFixture fixture; + fixture.coordinator.agentSessionUnavailable("gone"); + fixture.ops.transcriptIsEmpty = false; + + QVERIFY(fixture.coordinator.canHandOverSummary()); + fixture.coordinator.handOverSummary(); + QCOMPARE(fixture.compression.summaryStarts, 1); + + fixture.coordinator.summaryProduced("the summary"); + QVERIFY(fixture.ops.calls.contains("freshWithSummary:the summary")); + QVERIFY(!fixture.coordinator.readOnly()); + + fixture.coordinator.agentSessionUnavailable("gone again"); + fixture.compression.configurationIssue = "no chat configuration is assigned"; + QVERIFY(!fixture.coordinator.canHandOverSummary()); + QVERIFY(fixture.coordinator.summaryHandoverTooltip().contains("cannot be produced")); + fixture.coordinator.handOverSummary(); + QCOMPARE(fixture.compression.summaryStarts, 1); + + fixture.compression.configurationIssue.clear(); + fixture.ops.transcriptIsEmpty = true; + QVERIFY(!fixture.coordinator.canHandOverSummary()); + + fixture.ops.transcriptIsEmpty = false; + fixture.compression.running = true; + QVERIFY(fixture.coordinator.canHandOverSummary()); + fixture.coordinator.handOverSummary(); + QCOMPARE(fixture.compression.summaryStarts, 1); + QVERIFY(!fixture.errors.isEmpty()); +} + +void QodeAssistTest::testCoordinatorShrinkContextRoutesByBackendKind() +{ + CoordinatorFixture fixture; + fixture.ops.transcriptIsEmpty = false; + + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.compression.compressionStarts, 1); + QCOMPARE(fixture.compression.summaryStarts, 0); + + fixture.compression.configurationIssue = "no chat configuration is assigned"; + QVERIFY(!fixture.coordinator.canShrinkContext()); + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.compression.compressionStarts, 1); + QCOMPARE(fixture.errors.size(), 1); + QVERIFY(fixture.coordinator.shrinkContextTooltip().contains("Unavailable")); + fixture.compression.configurationIssue.clear(); + + fixture.ops.boundId = "agent-a"; + QVERIFY(fixture.coordinator.canShrinkContext()); + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.compression.compressionStarts, 1); + QCOMPARE(fixture.compression.summaryStarts, 1); + + fixture.ops.transcriptIsEmpty = true; + QVERIFY(!fixture.coordinator.canShrinkContext()); + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.compression.summaryStarts, 1); + QVERIFY(fixture.coordinator.shrinkContextTooltip().contains("nothing to summarise")); + + fixture.ops.transcriptIsEmpty = false; + fixture.compression.configurationIssue = "no chat configuration is assigned"; + QVERIFY(!fixture.coordinator.canShrinkContext()); + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.compression.summaryStarts, 1); + fixture.compression.configurationIssue.clear(); + + fixture.compression.running = true; + const int errorsBeforeBusy = fixture.errors.size(); + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.compression.summaryStarts, 1); + QCOMPARE(fixture.errors.size(), errorsBeforeBusy + 1); + + const int dispatchedBeforeBusySend = fixture.send.dispatched.size(); + fixture.coordinator.requestSend("while summarising", {}); + QCOMPARE(fixture.send.dispatched.size(), dispatchedBeforeBusySend); + QCOMPARE(fixture.errors.size(), errorsBeforeBusy + 2); + fixture.compression.running = false; + + fixture.coordinator.agentSessionUnavailable("gone"); + QVERIFY(!fixture.coordinator.canShrinkContext()); + const int errorsBeforeReadOnly = fixture.errors.size(); + fixture.coordinator.shrinkContext(); + QCOMPARE(fixture.errors.size(), errorsBeforeReadOnly + 1); + QCOMPARE(fixture.compression.summaryStarts, 1); + QCOMPARE(fixture.compression.compressionStarts, 1); +} + +void QodeAssistTest::testAcpBackendTracksAgentSlashCommands() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setAvailableCommandUpdates( + {QJsonArray{ + QJsonObject{ + {"name", QStringLiteral("help")}, + {"description", QStringLiteral("Show help")}, + {"input", QJsonObject{{"hint", QStringLiteral("topic")}}}}, + QJsonObject{ + {"name", QStringLiteral("review")}, + {"description", QStringLiteral("Review\nthe diff")}}}, + QJsonArray{ + QJsonObject{ + {"name", QStringLiteral("help")}, + {"description", QStringLiteral("Show help")}}}}); + }; + + int changes = 0; + QObject::connect( + &fixture.backend, + &Acp::AcpChatBackend::availableCommandsChanged, + &fixture.backend, + [&changes] { ++changes; }); + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QTRY_COMPARE(fixture.backend.availableCommands().size(), 1); + QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help")); + QCOMPARE(changes, 2); + + fixture.backend.clearToolSession("chat.json"); + QCOMPARE(fixture.backend.availableCommands().size(), 0); + QCOMPARE(changes, 3); +} + +void QodeAssistTest::testEarlyAgentCommandsSurviveEstablishment() +{ + AcpBackendFixture fixture; + fixture.configure = [](FakeAcpAgent *agent) { + agent->setCommandsBeforeSessionReply( + QJsonArray{QJsonObject{ + {"name", QStringLiteral("help")}, + {"description", QStringLiteral("Show help")}}}); + }; + + fixture.send("hi"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.backend.availableCommands().size(), 1); + QCOMPARE(fixture.backend.availableCommands().at(0).name, QString("help")); +} + +void QodeAssistTest::testAgentCommandInvocationSendsPlainPrompt() +{ + AcpBackendFixture fixture; + + fixture.send("/review src/main.cpp"); + + QTRY_COMPARE(fixture.eventsOfType().size(), 1); + QCOMPARE(fixture.agent->prompts().size(), 1); + + const QJsonArray prompt = fixture.agent->prompts().at(0); + QCOMPARE(prompt.size(), 1); + QCOMPARE(prompt.at(0).toObject().value("type").toString(), QString("text")); + QCOMPARE( + prompt.at(0).toObject().value("text").toString(), QString("/review src/main.cpp")); +} + +void QodeAssistTest::testFileMentionSelectionFollowsChatToolsSetting() +{ + auto &enableChatTools = Settings::chatAssistantSettings().enableChatTools; + const bool originalValue = enableChatTools(); + const auto restore = qScopeGuard( + [&] { enableChatTools.setValue(originalValue, Utils::BaseAspect::BeQuiet); }); + + Chat::FileMentionItem item; + QStringList attachRequests; + connect( + &item, + &Chat::FileMentionItem::fileAttachRequested, + this, + [&attachRequests](const QStringList &paths) { attachRequests += paths; }); + + enableChatTools.setValue(true, Utils::BaseAspect::BeQuiet); + QVariantMap result = item.handleFileSelection("/proj/src/main.cpp", "src/main.cpp", "proj", "main"); + QCOMPARE(result.value("mode").toString(), QString("mention")); + QCOMPARE(result.value("mentionText").toString(), QString("@main.cpp ")); + QVERIFY(attachRequests.isEmpty()); + + enableChatTools.setValue(false, Utils::BaseAspect::BeQuiet); + result = item.handleFileSelection("/proj/src/main.cpp", "src/main.cpp", "proj", "main"); + QCOMPARE(result.value("mode").toString(), QString("attach")); + QCOMPARE(attachRequests, QStringList{"/proj/src/main.cpp"}); +} + +void QodeAssistTest::testChatModelExposesSessionRowsDirectly() +{ + Chat::ChatModel model; + + Session::MessageRow tool; + tool.kind = Session::RowKind::AgentTool; + tool.id = "t1"; + tool.toolKind = "edit"; + tool.toolStatus = "completed"; + tool.toolDetails = QJsonObject{{"diffs", QJsonArray{QJsonObject{{"path", "/p/main.cpp"}}}}}; + tool.usage = Session::Usage{10, 5, 0, 0}; + + model.resetMessages({tool}); + + QCOMPARE(model.rowCount(), 1); + QCOMPARE( + model.data(model.index(0), Chat::ChatModel::RoleType).value(), + Chat::ChatModel::ChatRole::Tool); + QCOMPARE(model.data(model.index(0), Chat::ChatModel::ToolKind).toString(), QString("edit")); + + const QVariant details = model.data(model.index(0), Chat::ChatModel::ToolDetails); + QCOMPARE(details.value(), tool.toolDetails); + QVERIFY(details.toMap().contains("diffs")); + + QCOMPARE(model.data(model.index(0), Chat::ChatModel::PromptTokens).toInt(), 10); + QCOMPARE(model.data(model.index(0), Chat::ChatModel::TotalTokens).toInt(), 15); + + Session::MessageRow updated = tool; + updated.toolStatus = "failed"; + model.updateMessage(0, updated); + QCOMPARE(model.data(model.index(0), Chat::ChatModel::ToolStatus).toString(), QString("failed")); +} + +void QodeAssistTest::testBlockCodecRejectsForgedMarkers() +{ + const QJsonObject payload{{"key", "value"}}; + + for (const QLatin1StringView marker : Session::knownPayloadMarkers()) { + const QString encoded = Session::encodeMarkerPayload(marker, payload); + QVERIFY(encoded.startsWith(marker)); + QVERIFY(Session::hasPayloadMarker(marker, encoded)); + + const auto decoded = Session::decodeMarkerPayload(marker, encoded); + QVERIFY(decoded.has_value()); + QCOMPARE(*decoded, payload); + + QVERIFY(!Session::hasPayloadMarker(marker, QStringLiteral(" ") + encoded)); + QVERIFY(!Session::decodeMarkerPayload(marker, QStringLiteral(" ") + encoded)); + QVERIFY(!Session::decodeMarkerPayload(marker, QStringLiteral("echoed: ") + encoded)); + QVERIFY(!Session::decodeMarkerPayload(marker, QString(marker))); + QVERIFY(!Session::decodeMarkerPayload(marker, marker + QStringLiteral("not json"))); + QVERIFY(!Session::decodeMarkerPayload(marker, marker + QStringLiteral("[1, 2]"))); + } +} + +void QodeAssistTest::testFileEditParsingRequiresTheMarkerAtTheStart() +{ + const QString encoded = Session::encodeFileEditPayload(QJsonObject{{"edit_id", "e1"}}); + + QVERIFY(Session::isFileEditPayload(encoded)); + const auto parsed = Session::parseFileEditPayload(encoded); + QVERIFY(parsed.has_value()); + QCOMPARE(parsed->value("edit_id").toString(), QString("e1")); + + const QString echoed = QStringLiteral("tool result quoting ") + encoded; + QVERIFY(!Session::isFileEditPayload(echoed)); + QVERIFY(!Session::parseFileEditPayload(echoed)); +} + +void QodeAssistTest::testPermissionOptionsCarryAllowsAcrossTheSeam() +{ + Session::PermissionBlock block; + block.requestId = "p1"; + block.title = "Edit main.cpp"; + block.options + = {Session::PermissionOption{"a", "Allow", "allow_once"}, + Session::PermissionOption{"b", "Always", "allow_always"}, + Session::PermissionOption{"c", "Deny", "reject_once"}}; + + const QString encoded = Session::encodePermissionBlock(block); + const auto payload = Session::decodeMarkerPayload(Session::permissionPayloadMarker, encoded); + QVERIFY(payload.has_value()); + + const QJsonArray options = payload->value("options").toArray(); + QCOMPARE(options.size(), 3); + QCOMPARE(options.at(0).toObject().value("allows").toBool(), true); + QCOMPARE(options.at(1).toObject().value("allows").toBool(), true); + QCOMPARE(options.at(2).toObject().value("allows").toBool(), false); + + const auto decoded = Session::decodePermissionBlock(encoded); + QVERIFY(decoded.has_value()); + QCOMPARE(decoded->options, block.options); +} + +void QodeAssistTest::testChatFileStoreRoundTripsStoredContent() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + const QString chatFilePath = dir.filePath("chat.json"); + const QByteArray original = QByteArrayLiteral("int main() { return 0; }\n"); + + QString storedPath; + QVERIFY(Chat::ChatFileStore::saveContentToStorage( + chatFilePath, "main.cpp", QString::fromLatin1(original.toBase64()), storedPath)); + QVERIFY(!storedPath.isEmpty()); + + QCOMPARE(Chat::ChatFileStore::loadRawContentFromStorage(chatFilePath, storedPath), original); + QCOMPARE( + Chat::ChatFileStore::loadContentFromStorage(chatFilePath, storedPath), + QString::fromLatin1(original.toBase64())); + + const QString contentFolder = Chat::ChatFileStore::getChatContentFolder(chatFilePath); + QVERIFY(QFileInfo(QDir(contentFolder).filePath(storedPath)).exists()); +} + +void QodeAssistTest::testLegacyChatFileLoadsThroughTheFileStore() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + + QJsonObject root; + root["version"] = "0.2"; + root["messages"] = QJsonArray{ + QJsonObject{{"role", 1}, {"content", "explain this"}, {"id", "u1"}}, + QJsonObject{{"role", 2}, {"content", "here is the answer"}, {"id", "r1"}}}; + + const QString filePath = dir.filePath("legacy.json"); + QFile file(filePath); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.write(QJsonDocument(root).toJson()); + file.close(); + + Session::ConversationHistory history; + Acp::AgentBinding binding; + const Chat::SerializationResult result + = Chat::ChatFileStore::loadFromFile(history, binding, filePath); + + QVERIFY(result.success); + QVERIFY(result.warningMessage.isEmpty()); + QVERIFY(binding.isEmpty()); + QCOMPARE(history.size(), 2); + QCOMPARE(history.at(0).role, Session::MessageRole::User); + QCOMPARE( + std::get(history.at(0).blocks.at(0)), + (Session::TextBlock{"explain this"})); + QCOMPARE(history.at(1).role, Session::MessageRole::Assistant); +} + +void QodeAssistTest::testPickerObservesTheSharedAgentCatalog() +{ + Acp::AgentCatalogStore store; + Chat::ChatConfigurationController controller; + + QSignalSpy listChanged( + &controller, &Chat::ChatConfigurationController::availableConfigurationsChanged); + + controller.setAgentCatalog(&store); + QVERIFY(listChanged.count() >= 1); + QVERIFY(!controller.availableConfigurations().isEmpty()); + + const int before = listChanged.count(); + emit store.catalogChanged(); + QCOMPARE(listChanged.count(), before + 1); + + controller.setAgentCatalog(&store); + QCOMPARE(listChanged.count(), before + 1); +} + } // namespace QodeAssist diff --git a/tests/QodeAssistTest.hpp b/tests/QodeAssistTest.hpp index 8749e30..c10d041 100644 --- a/tests/QodeAssistTest.hpp +++ b/tests/QodeAssistTest.hpp @@ -98,11 +98,19 @@ private slots: void testSessionStreamsTextThinkingAndTools(); void testSessionTrimsStreamedText(); void testSessionAbandoningTheTurnCancelsTheBackend(); + void testSwitchingBackendsCancelsTheActiveTurn(); + void testSessionForwardsTitleFromTheEventStream(); void testSessionProjectionMatchesHistory(); void testSessionAccumulatesStreamedThinking(); void testSessionKeepsThinkingAfterToolContinuation(); void testSessionDropsPreToolTextWhenAsked(); void testSessionToolResultAppendsFileEditRow(); + void testSessionRepeatedToolResultKeepsOneFileEdit(); + void testRunningToolStatusInterruptsOnTurnEnd(); + void testRunningToolStatusInterruptsOnLoad(); + void testAgentDiffBecomesAppliedFileEdit(); + void testAgentDiffSkipsUnrevertableRegistrations(); + void testAgentFileEditRevertRoundTrip(); void testSessionIgnoresEchoedFileEditMarker(); void testSessionTruncationKeepsBlockOrder(); void testHistorySnapshotReportsUnreadableBlocks(); @@ -112,13 +120,138 @@ private slots: void testSessionIgnoresFailureFromAbandonedTurn(); void testSessionTruncateRowsRewritesHistory(); + void testSessionUpsertsAgentToolCalls(); + void testLlmToolLifecycleFlowsThroughOneEvent(); + void testSessionKeepsOnePlanPerTurn(); + void testAgentActivityKeepsStreamedTextIntact(); + void testAgentToolUpdateTouchesOnlyItsOwnRow(); + void testUsageSkipsRowsThatCannotShowIt(); + void testAgentActivityBlocksSurviveReload(); + + void testEveryRowKindProjectsInOrder(); + void testPromptAudienceKeepsToolsAndThinking(); + void testCompressionAudienceDropsToolsAndThinking(); + void testTokenCountAudienceCountsToolsAndThinking(); + + void testSessionRendersPermissionRequestWithItsOptions(); + void testSessionForwardsPermissionAnswerToTheBackend(); + void testSessionAllowAlwaysSuppressesRepeatPromptsOfTheSameKind(); + void testSessionAllowAlwaysDoesNotCoverOtherToolKinds(); + void testSessionNeverAutoAnswersUnclassifiedTools(); + void testSessionIgnoresAnswersToUnknownPermissionRequests(); + void testSessionMarksUnansweredPermissionsCancelled(); + void testSessionRejectAlwaysSuppressesRepeatPromptsOfTheSameKind(); + void testSessionAutoAnswerFallsBackToTheAlwaysOption(); + void testSessionDoesNotAutoAnswerWithoutAMatchingOption(); + void testSessionAllowAlwaysDoesNotSurviveAnotherConversation(); + void testSessionAnswersTheLiveRequestWhenAnOldIdIsReused(); + void testSessionCancelsPermissionsTheBackendNeverResolved(); + void testSessionCancelsPermissionsTheBackendRefused(); + void testPermissionBlockReopensAsUnanswerable(); + void testTurnContextCollectsPartsFromPorts(); + void testTurnContextSkipsWhatTheBackendDoesNotNeed(); + void testFencedFileBlockOutgrowsBackticksInContent(); void testTurnContextSkillCommandScanning(); void testTurnContextWithoutSkillsPort(); - void testLinkedFilesHeaderSurvivesUnreadablePaths(); void testSystemPromptRenderingWithProject(); void testSystemPromptRenderingWithoutProject(); + void testAgentRegistryParsesEveryDistributionKind(); + void testAgentRegistryReportsUnusableEntries(); + void testAgentRegistryParsesSingleAgentUserFile(); + void testAgentCatalogAppliesMergePriority(); + void testAgentCatalogUserFileMakesBinaryAgentLaunchable(); + void testAgentLaunchConfigUsesRunnerConventions(); + void testAgentSearchPathsSplitting(); + void testAgentExtraSearchPathsReachTheChildEnvironment(); + void testAgentForwardedVariablesReachTheChildEnvironment(); + void testBundledAgentSnapshotParses(); + + void testAcpBackendStreamsTextAndThinking(); + void testAcpBackendStartsSessionInWorkingDirectory(); + void testAcpBackendAuthenticatesWhenTheAgentAsksForIt(); + void testAcpBackendReportsPromptFailure(); + void testAcpBackendCancelInterruptsTheTurn(); + void testAcpBackendSendsAttachmentsAsEmbeddedResources(); + void testAcpBackendInlinesAttachmentsWithoutEmbeddedContextSupport(); + void testAcpBackendReportsAttachmentsItCannotLoad(); + void testAcpBackendSendsImagesWhenTheAgentAcceptsThem(); + void testAcpBackendNamesImagesTheAgentCannotAccept(); + void testAcpBackendFencesAttachmentsThatContainFences(); + void testAcpBackendTruncatesOversizedAttachments(); + void testAcpBackendRejectsAnEmptyTurn(); + void testAcpBackendSendsAttachmentsWithoutAnyMessageText(); + void testAcpBackendRaisesAgentPermissionRequests(); + void testAcpBackendSendsThePermissionAnswerToTheAgent(); + void testAcpBackendCancelsOutstandingPermissionRequests(); + + void testTurnLedgerTracksTheActiveTurn(); + void testTurnLedgerDrainsPermissionsOnTurnEnd(); + void testLlmBackendStreamsThroughAFakeClient(); + void testRenderHistoryKeepsToolExchangePairs(); + void testLlmBackendIgnoresEventsFromOtherRequests(); + void testLlmBackendPermissionRoundTrip(); + void testLlmBackendDrainsPermissionsWhenTheTurnEnds(); + + void testCoordinatorSwitchConfirmAndCancelRoundTrip(); + void testCoordinatorQuarantinesAndRestoresAgentBindings(); + void testCoordinatorRefusesIntentsWhileReadOnly(); + void testCoordinatorDefersTheSendForAutoCompress(); + void testCoordinatorGatesTheSummaryHandover(); + void testCoordinatorShrinkContextRoutesByBackendKind(); + + void testAcpBackendTracksAgentSlashCommands(); + void testEarlyAgentCommandsSurviveEstablishment(); + void testAgentCommandInvocationSendsPlainPrompt(); + + void testFileMentionSelectionFollowsChatToolsSetting(); + + void testChatModelExposesSessionRowsDirectly(); + + void testBlockCodecRejectsForgedMarkers(); + void testFileEditParsingRequiresTheMarkerAtTheStart(); + void testPermissionOptionsCarryAllowsAcrossTheSeam(); + + void testChatFileStoreRoundTripsStoredContent(); + void testLegacyChatFileLoadsThroughTheFileStore(); + + void testPickerObservesTheSharedAgentCatalog(); + void testAcpBackendSeedsAHandoverSummaryOnce(); + void testAcpBackendDropsAHandoverSummaryAfterACancelledTurn(); + void testAcpBackendDropsAHandoverSummaryWithTheConversation(); + void testToolSafetyDefaultsToMutating(); + void testToolsManagerWithoutAGateRunsImmediately(); + void testToolsManagerGateCanDeclineAToolCall(); + void testToolsManagerGateCanAllowAToolCall(); + void testToolsManagerGateResumesTheQueueAfterADenial(); + void testKnowledgeServerExposesOnlyReadOnlyTools(); + void testKnowledgeServerBindsLoopbackOnAnEphemeralPort(); + void testAcpBackendOffersKnowledgeToHttpCapableAgents(); + void testAcpBackendWithholdsKnowledgeFromAgentsWithoutHttpMcp(); + void testAcpBackendStopsTheKnowledgeServerWithTheSession(); + void testAcpBackendSurvivesAKnowledgeServerThatWillNotStart(); + void testAcpBackendResumesAPersistedSession(); + void testAcpBackendReportsAnUnresumableSession(); + void testAcpBackendRefusesToResumeWithoutAgentSupport(); + void testAcpBackendStartsFreshAfterAFailedResume(); + void testAgentBindingKeepsTheResumeTargetBeforeTheFirstTurn(); + void testAcpBackendCarriesTheLiveSessionIntoAResume(); + void testAcpBackendEstablishesTheSessionOnlyOnce(); + void testAgentBindingRejectsMalformedRecords(); + void testMalformedAgentBindingLeavesTheChatUnbound(); + void testAgentBindingRoundTripsThroughTheChatFile(); + void testChatFileWithoutAnAgentLoadsUnbound(); + void testAcpBackendMapsToolCallLifecycle(); + void testAcpBackendMapsPlanUpdates(); + void testAcpBackendReportsUsageFromThePromptResult(); + void testAcpBackendIgnoresTheCumulativeContextGauge(); + void testAcpBackendForwardsTheAgentSuggestedTitle(); + void testAcpBackendDeclinesAmbiguousPermissionOptions(); + void testAcpBackendClampsOversizedPermissionText(); + void testAcpBackendCancelsPermissionsWhenTheTurnCompletes(); + void testAcpBackendCancelsPermissionRequestsWhenTheTurnFails(); + private: Context::DocumentContextReader createReader(const QString &text); QSharedPointer createSettingsForWholeFile();