deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/ai-chat/.eslintrc.js
Normal file
10
packages/ai-chat/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
96
packages/ai-chat/README.md
Normal file
96
packages/ai-chat/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
<div align='center'>
|
||||
|
||||
<br />
|
||||
|
||||
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
|
||||
|
||||
<h2>ECLIPSE THEIA - AI CHAT EXTENSION</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
|
||||
## Description
|
||||
|
||||
The `@theia/ai-chat` extension provides the concept of a language model chat to Theia.
|
||||
It serves as the basis for `@theia/ai-chat-ui` to provide the Chat UI.
|
||||
|
||||
## Tool Context Patterns
|
||||
|
||||
When implementing tool handlers, there are two patterns depending on whether your tool requires chat-specific features:
|
||||
|
||||
### Generic Tools (no chat dependency)
|
||||
|
||||
For tools that only need basic context like cancellation support:
|
||||
|
||||
```typescript
|
||||
import { ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core';
|
||||
|
||||
@injectable()
|
||||
export class MyGenericTool implements ToolProvider {
|
||||
getTool(): ToolRequest {
|
||||
return {
|
||||
id: 'myTool',
|
||||
name: 'myTool',
|
||||
description: 'A generic tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async (args: string, ctx?: ToolInvocationContext) => {
|
||||
if (ctx?.cancellationToken?.isCancellationRequested) {
|
||||
return JSON.stringify({ error: 'Operation cancelled' });
|
||||
}
|
||||
// Tool implementation
|
||||
return 'result';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Chat-Bound Tools (requires chat session)
|
||||
|
||||
For tools that need access to the chat session, request model, or response:
|
||||
|
||||
```typescript
|
||||
import { assertChatContext, ChatToolContext } from '@theia/ai-chat';
|
||||
import { ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core';
|
||||
|
||||
@injectable()
|
||||
export class MyChatTool implements ToolProvider {
|
||||
getTool(): ToolRequest {
|
||||
return {
|
||||
id: 'myChatTool',
|
||||
name: 'myChatTool',
|
||||
description: 'A chat-bound tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async (args: string, ctx?: ToolInvocationContext) => {
|
||||
assertChatContext(ctx); // Throws if not in chat context
|
||||
if (ctx.cancellationToken?.isCancellationRequested) {
|
||||
return JSON.stringify({ error: 'Operation cancelled' });
|
||||
}
|
||||
// Access chat-specific features
|
||||
const sessionId = ctx.request.session.id;
|
||||
ctx.request.session.changeSet.addElements(...);
|
||||
return 'result';
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `assertChatContext()` function serves as both a runtime validator and TypeScript type guard, ensuring the context is a `ChatToolContext` with `request` and `response` properties.
|
||||
|
||||
## Additional Information
|
||||
|
||||
- [API documentation for `@theia/ai-chat`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_ai-chat.html)
|
||||
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
|
||||
- [Theia - Website](https://theia-ide.org/)
|
||||
|
||||
## License
|
||||
|
||||
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
|
||||
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
|
||||
|
||||
## Trademark
|
||||
|
||||
"Theia" is a trademark of the Eclipse Foundation
|
||||
<https://www.eclipse.org/theia>
|
||||
58
packages/ai-chat/package.json
Normal file
58
packages/ai-chat/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@theia/ai-chat",
|
||||
"version": "1.68.0",
|
||||
"description": "Theia - AI Chat Extension",
|
||||
"dependencies": {
|
||||
"@theia/ai-core": "1.68.0",
|
||||
"@theia/core": "1.68.0",
|
||||
"@theia/editor": "1.68.0",
|
||||
"@theia/file-search": "1.68.0",
|
||||
"@theia/filesystem": "1.68.0",
|
||||
"@theia/monaco": "1.68.0",
|
||||
"@theia/monaco-editor-core": "1.96.302",
|
||||
"@theia/workspace": "1.68.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/common",
|
||||
"theiaExtensions": [
|
||||
{
|
||||
"frontend": "lib/browser/ai-chat-frontend-module",
|
||||
"backend": "lib/node/ai-chat-backend-module"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"theia-extension"
|
||||
],
|
||||
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/eclipse-theia/theia.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/eclipse-theia/theia/issues"
|
||||
},
|
||||
"homepage": "https://github.com/eclipse-theia/theia",
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "theiaext build",
|
||||
"clean": "theiaext clean",
|
||||
"compile": "theiaext compile",
|
||||
"lint": "theiaext lint",
|
||||
"test": "theiaext test",
|
||||
"watch": "theiaext watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@theia/ext-scripts": "1.68.0"
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "../../configs/nyc.json"
|
||||
},
|
||||
"gitHead": "21358137e41342742707f660b8e222f940a27652"
|
||||
}
|
||||
243
packages/ai-chat/src/browser/agent-delegation-tool.ts
Normal file
243
packages/ai-chat/src/browser/agent-delegation-tool.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ToolInvocationContext, ToolProvider, ToolRequest } from '@theia/ai-core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
assertChatContext,
|
||||
ChatAgentService,
|
||||
ChatAgentServiceFactory,
|
||||
ChatRequest,
|
||||
ChatService,
|
||||
ChatServiceFactory,
|
||||
ChatToolContext,
|
||||
MutableChatModel,
|
||||
MutableChatRequestModel,
|
||||
ChatSession,
|
||||
ChatRequestInvocation,
|
||||
} from '../common';
|
||||
import { DelegationResponseContent } from './delegation-response-content';
|
||||
|
||||
export const AGENT_DELEGATION_FUNCTION_ID = 'delegateToAgent';
|
||||
|
||||
@injectable()
|
||||
export class AgentDelegationTool implements ToolProvider {
|
||||
static ID = AGENT_DELEGATION_FUNCTION_ID;
|
||||
|
||||
@inject(ChatAgentServiceFactory)
|
||||
protected readonly getChatAgentService: () => ChatAgentService;
|
||||
|
||||
@inject(ChatServiceFactory)
|
||||
protected readonly getChatService: () => ChatService;
|
||||
|
||||
getTool(): ToolRequest {
|
||||
return {
|
||||
id: AgentDelegationTool.ID,
|
||||
name: AgentDelegationTool.ID,
|
||||
description:
|
||||
'Delegate a task or question to a specific AI agent. IMPORTANT: When you delegate a task or question to a specific AI agent using this tool, ' +
|
||||
'remember that each sub-agent operates solely within its specialized capabilities and tools and does not have access to previous conversation context ' +
|
||||
' or external systems. Therefore, it is crucial to provide all necessary context and detailed information directly within your request to ensure accurate ' +
|
||||
'and effective task completion.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agentId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The ID of the AI agent to delegate the task to.',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The task, question, or prompt to pass to the specified agent.',
|
||||
},
|
||||
},
|
||||
required: ['agentId', 'prompt'],
|
||||
},
|
||||
handler: (arg_string: string, ctx?: ToolInvocationContext) => {
|
||||
assertChatContext(ctx);
|
||||
return this.delegateToAgent(arg_string, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async delegateToAgent(
|
||||
arg_string: string,
|
||||
ctx: ChatToolContext
|
||||
): Promise<string> {
|
||||
if (ctx.cancellationToken?.isCancellationRequested) {
|
||||
return 'Operation cancelled by user';
|
||||
}
|
||||
|
||||
try {
|
||||
const args = JSON.parse(arg_string);
|
||||
const { agentId, prompt } = args;
|
||||
|
||||
if (!agentId || !prompt) {
|
||||
const errorMsg = 'Both agentId and prompt parameters are required.';
|
||||
console.error(errorMsg, { agentId, prompt });
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
// Check if the specified agent exists
|
||||
const agent = this.getChatAgentService().getAgent(agentId);
|
||||
if (!agent) {
|
||||
const availableAgents = this.getChatAgentService()
|
||||
.getAgents()
|
||||
.map(a => a.id);
|
||||
const errorMsg = `Agent '${agentId}' not found or not enabled. Available agents: ${availableAgents.join(', ')}`;
|
||||
console.error(errorMsg);
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
let newSession;
|
||||
try {
|
||||
// FIXME: this creates a new conversation visible in the UI (Panel), which we don't want
|
||||
// It is not possible to start a session without specifying a location (default=Panel)
|
||||
const chatService = this.getChatService();
|
||||
|
||||
// Store the current active session to restore it after delegation
|
||||
const currentActiveSession = chatService.getActiveSession();
|
||||
|
||||
newSession = chatService.createSession(
|
||||
undefined,
|
||||
{ focus: false },
|
||||
agent
|
||||
);
|
||||
|
||||
// Immediately restore the original active session to avoid confusing the user
|
||||
if (currentActiveSession) {
|
||||
chatService.setActiveSession(currentActiveSession.id, { focus: false });
|
||||
}
|
||||
|
||||
// Setup ChangeSet bubbling from delegated session to parent session
|
||||
this.setupChangeSetBubbling(newSession, ctx.request.session);
|
||||
} catch (sessionError) {
|
||||
const errorMsg = `Failed to create chat session for agent '${agentId}': ${sessionError instanceof Error ? sessionError.message : sessionError}`;
|
||||
console.error(errorMsg, sessionError);
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
// Send the request
|
||||
const chatRequest: ChatRequest = {
|
||||
text: `@${agentId} ${prompt}`,
|
||||
};
|
||||
|
||||
let response: ChatRequestInvocation | undefined;
|
||||
try {
|
||||
if (ctx.cancellationToken?.isCancellationRequested) {
|
||||
return 'Operation cancelled by user';
|
||||
}
|
||||
|
||||
const chatService = this.getChatService();
|
||||
response = await chatService.sendRequest(
|
||||
newSession.id,
|
||||
chatRequest
|
||||
);
|
||||
|
||||
if (ctx.cancellationToken) {
|
||||
ctx.cancellationToken.onCancellationRequested(
|
||||
async () => {
|
||||
if (response) {
|
||||
((await response?.requestCompleted) as MutableChatRequestModel).cancel();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (sendError) {
|
||||
const errorMsg = `Failed to send request to agent '${agentId}': ${sendError instanceof Error ? sendError.message : sendError}`;
|
||||
console.error(errorMsg, sendError);
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
// Add the response content immediately to enable streaming
|
||||
// The renderer will handle the streaming updates
|
||||
ctx.response.response.addContent(
|
||||
new DelegationResponseContent(agent.name, prompt, response)
|
||||
);
|
||||
|
||||
try {
|
||||
// Wait for completion to return the final result as tool output
|
||||
const result = await response.responseCompleted;
|
||||
const stringResult = result.response.asString();
|
||||
|
||||
// Clean up the session after completion (no need to await)
|
||||
const chatService = this.getChatService();
|
||||
chatService.deleteSession(newSession.id).catch(error => {
|
||||
console.error('Failed to delete delegated session', error);
|
||||
});
|
||||
|
||||
// Return the raw text to the top-level Agent, as a tool result
|
||||
return stringResult;
|
||||
} catch (completionError) {
|
||||
if (
|
||||
completionError.message &&
|
||||
completionError.message.includes('cancelled')
|
||||
) {
|
||||
return 'Operation cancelled by user';
|
||||
}
|
||||
const errorMsg = `Failed to complete response from agent '${agentId}': ${completionError instanceof Error ? completionError.message : completionError}`;
|
||||
console.error(errorMsg, completionError);
|
||||
return errorMsg;
|
||||
}
|
||||
} else {
|
||||
const errorMsg = `Delegation to agent '${agentId}' has failed: no response returned.`;
|
||||
console.error(errorMsg);
|
||||
return errorMsg;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delegate to agent', error);
|
||||
return JSON.stringify({
|
||||
error: `Failed to parse arguments or delegate to agent: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up monitoring of the ChangeSet in the delegated session and bubbles changes to the parent session.
|
||||
* @param delegatedSession The session created for the delegated agent
|
||||
* @param parentModel The parent session model that should receive the bubbled changes
|
||||
* @param agentName The name of the agent for attribution purposes
|
||||
*/
|
||||
private setupChangeSetBubbling(
|
||||
delegatedSession: ChatSession,
|
||||
parentModel: MutableChatModel
|
||||
): void {
|
||||
// Monitor ChangeSet for bubbling
|
||||
delegatedSession.model.changeSet.onDidChange(_event => {
|
||||
this.bubbleChangeSet(delegatedSession, parentModel);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bubbles the ChangeSet from the delegated session to the parent session.
|
||||
* @param delegatedSession The session from which to bubble changes
|
||||
* @param parentModel The parent session model to receive the bubbled changes
|
||||
* @param agentName The name of the agent for attribution purposes
|
||||
*/
|
||||
private bubbleChangeSet(
|
||||
delegatedSession: ChatSession,
|
||||
parentModel: MutableChatModel
|
||||
): void {
|
||||
const delegatedElements = delegatedSession.model.changeSet.getElements();
|
||||
if (delegatedElements.length > 0) {
|
||||
parentModel.changeSet.setTitle(delegatedSession.model.changeSet.title);
|
||||
parentModel.changeSet.addElements(...delegatedElements);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIContextVariable, AIVariableService } from '@theia/ai-core';
|
||||
import { Command, CommandContribution, CommandRegistry } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatService } from '../common';
|
||||
|
||||
export const VARIABLE_ADD_CONTEXT_COMMAND: Command = Command.toLocalizedCommand({
|
||||
id: 'add-context-variable',
|
||||
label: 'Add context variable'
|
||||
}, 'theia/ai/chat-ui/addContextVariable');
|
||||
|
||||
@injectable()
|
||||
export class AIChatFrontendContribution implements CommandContribution {
|
||||
@inject(AIVariableService)
|
||||
protected readonly variableService: AIVariableService;
|
||||
@inject(ChatService)
|
||||
protected readonly chatService: ChatService;
|
||||
|
||||
registerCommands(registry: CommandRegistry): void {
|
||||
registry.registerCommand(VARIABLE_ADD_CONTEXT_COMMAND, {
|
||||
execute: (...args) => args.length > 1 && this.addContextVariable(args[0], args[1]),
|
||||
isVisible: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
async addContextVariable(variableName: string, arg: string | undefined): Promise<void> {
|
||||
const variable = this.variableService.getVariable(variableName);
|
||||
if (!variable || !AIContextVariable.is(variable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatService.getActiveSession()?.model.context.addVariables({ variable, arg });
|
||||
}
|
||||
}
|
||||
189
packages/ai-chat/src/browser/ai-chat-frontend-module.ts
Normal file
189
packages/ai-chat/src/browser/ai-chat-frontend-module.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Agent, AgentService, AIVariableContribution, bindToolProvider } from '@theia/ai-core/lib/common';
|
||||
import { bindContributionProvider, CommandContribution, PreferenceContribution } from '@theia/core';
|
||||
import { FrontendApplicationContribution, LabelProviderContribution } from '@theia/core/lib/browser';
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
ChatAgent,
|
||||
ChatAgentService,
|
||||
ChatAgentServiceImpl,
|
||||
ChatRequestParser,
|
||||
ChatRequestParserImpl,
|
||||
ChatService,
|
||||
ToolCallChatResponseContentFactory,
|
||||
PinChatAgent,
|
||||
ChatServiceFactory,
|
||||
ChatAgentServiceFactory
|
||||
} from '../common';
|
||||
import { ChatAgentsVariableContribution } from '../common/chat-agents-variable-contribution';
|
||||
import { CustomChatAgent } from '../common/custom-chat-agent';
|
||||
import { DefaultResponseContentFactory, DefaultResponseContentMatcherProvider, ResponseContentMatcherProvider } from '../common/response-content-matcher';
|
||||
import { aiChatPreferences } from '../common/ai-chat-preferences';
|
||||
import { ChangeSetElementArgs, ChangeSetFileElement, ChangeSetFileElementFactory } from './change-set-file-element';
|
||||
import { AICustomAgentsFrontendApplicationContribution } from './custom-agent-frontend-application-contribution';
|
||||
import { FrontendChatServiceImpl } from './frontend-chat-service';
|
||||
import { CustomAgentFactory } from './custom-agent-factory';
|
||||
import { ChatToolRequestService } from '../common/chat-tool-request-service';
|
||||
import { FrontendChatToolRequestService } from './chat-tool-request-service';
|
||||
import { ChangeSetFileService } from './change-set-file-service';
|
||||
import { ContextVariableLabelProvider } from './context-variable-label-provider';
|
||||
import { ContextFileVariableLabelProvider } from './context-file-variable-label-provider';
|
||||
import { FileChatVariableContribution } from './file-chat-variable-contribution';
|
||||
import { ContextSummaryVariableContribution } from '../common/context-summary-variable';
|
||||
import { ContextDetailsVariableContribution } from '../common/context-details-variable';
|
||||
import { ChangeSetVariableContribution } from './change-set-variable';
|
||||
import { ChatSessionNamingAgent, ChatSessionNamingService } from '../common/chat-session-naming-service';
|
||||
import { ChangeSetDecorator, ChangeSetDecoratorService } from './change-set-decorator-service';
|
||||
import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent';
|
||||
import { TaskContextVariableContribution } from './task-context-variable-contribution';
|
||||
import { TaskContextVariableLabelProvider } from './task-context-variable-label-provider';
|
||||
import { TaskContextService, TaskContextStorageService } from './task-context-service';
|
||||
import { InMemoryTaskContextStorage } from './task-context-storage-service';
|
||||
import { AIChatFrontendContribution } from './ai-chat-frontend-contribution';
|
||||
import { ImageContextVariableContribution } from './image-context-variable-contribution';
|
||||
import { AgentDelegationTool } from './agent-delegation-tool';
|
||||
import { ToolConfirmationManager } from './chat-tool-preference-bindings';
|
||||
import { bindChatToolPreferences } from '../common/chat-tool-preferences';
|
||||
import { ChatSessionStore } from '../common/chat-session-store';
|
||||
import { ChatSessionStoreImpl } from './chat-session-store-impl';
|
||||
import {
|
||||
ChatContentDeserializerContribution,
|
||||
ChatContentDeserializerRegistry,
|
||||
ChatContentDeserializerRegistryImpl,
|
||||
DefaultChatContentDeserializerContribution
|
||||
} from '../common/chat-content-deserializer';
|
||||
import {
|
||||
ChangeSetElementDeserializerContribution,
|
||||
ChangeSetElementDeserializerRegistry,
|
||||
ChangeSetElementDeserializerRegistryImpl
|
||||
} from '../common/change-set-element-deserializer';
|
||||
import { ChangeSetFileElementDeserializerContribution } from './change-set-file-element-deserializer';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bindContributionProvider(bind, ChatAgent);
|
||||
|
||||
bind(ChatContentDeserializerRegistryImpl).toSelf().inSingletonScope();
|
||||
bind(ChatContentDeserializerRegistry).toService(ChatContentDeserializerRegistryImpl);
|
||||
bindContributionProvider(bind, ChatContentDeserializerContribution);
|
||||
bind(ChatContentDeserializerContribution).to(DefaultChatContentDeserializerContribution).inSingletonScope();
|
||||
|
||||
bind(ChangeSetElementDeserializerRegistryImpl).toSelf().inSingletonScope();
|
||||
bind(ChangeSetElementDeserializerRegistry).toService(ChangeSetElementDeserializerRegistryImpl);
|
||||
bindContributionProvider(bind, ChangeSetElementDeserializerContribution);
|
||||
bind(ChangeSetElementDeserializerContribution).to(ChangeSetFileElementDeserializerContribution).inSingletonScope();
|
||||
|
||||
bind(ChatSessionStoreImpl).toSelf().inSingletonScope();
|
||||
bind(ChatSessionStore).toService(ChatSessionStoreImpl);
|
||||
|
||||
bind(FrontendChatToolRequestService).toSelf().inSingletonScope();
|
||||
bind(ChatToolRequestService).toService(FrontendChatToolRequestService);
|
||||
|
||||
bind(ChatAgentServiceImpl).toSelf().inSingletonScope();
|
||||
bind(ChatAgentService).toService(ChatAgentServiceImpl);
|
||||
bind(PinChatAgent).toConstantValue(true);
|
||||
|
||||
bind(ChatSessionNamingService).toSelf().inSingletonScope();
|
||||
bind(ChatSessionNamingAgent).toSelf().inSingletonScope();
|
||||
bind(Agent).toService(ChatSessionNamingAgent);
|
||||
|
||||
bindContributionProvider(bind, ResponseContentMatcherProvider);
|
||||
bind(DefaultResponseContentMatcherProvider).toSelf().inSingletonScope();
|
||||
bind(ResponseContentMatcherProvider).toService(DefaultResponseContentMatcherProvider);
|
||||
bind(DefaultResponseContentFactory).toSelf().inSingletonScope();
|
||||
|
||||
bind(AIVariableContribution).to(ChatAgentsVariableContribution).inSingletonScope();
|
||||
|
||||
bind(ChatRequestParserImpl).toSelf().inSingletonScope();
|
||||
bind(ChatRequestParser).toService(ChatRequestParserImpl);
|
||||
|
||||
bind(FrontendChatServiceImpl).toSelf().inSingletonScope();
|
||||
bind(ChatService).toService(FrontendChatServiceImpl);
|
||||
|
||||
bind(ChatServiceFactory).toDynamicValue(ctx => () =>
|
||||
ctx.container.get<ChatService>(ChatService)
|
||||
);
|
||||
bind(ChatAgentServiceFactory).toDynamicValue(ctx => () =>
|
||||
ctx.container.get<ChatAgentService>(ChatAgentService)
|
||||
);
|
||||
|
||||
bind(PreferenceContribution).toConstantValue({ schema: aiChatPreferences });
|
||||
|
||||
// Tool confirmation preferences
|
||||
bindChatToolPreferences(bind);
|
||||
bind(ToolConfirmationManager).toSelf().inSingletonScope();
|
||||
|
||||
bind(CustomChatAgent).toSelf();
|
||||
bind(CustomAgentFactory).toFactory<CustomChatAgent, [string, string, string, string, string]>(
|
||||
ctx => (id: string, name: string, description: string, prompt: string, defaultLLM: string) => {
|
||||
const agent = ctx.container.get<CustomChatAgent>(CustomChatAgent);
|
||||
agent.id = id;
|
||||
agent.name = name;
|
||||
agent.description = description;
|
||||
agent.prompt = prompt;
|
||||
agent.languageModelRequirements = [{
|
||||
purpose: 'chat',
|
||||
identifier: defaultLLM,
|
||||
}];
|
||||
ctx.container.get<ChatAgentService>(ChatAgentService).registerChatAgent(agent);
|
||||
ctx.container.get<AgentService>(AgentService).registerAgent(agent);
|
||||
return agent;
|
||||
});
|
||||
bind(FrontendApplicationContribution).to(AICustomAgentsFrontendApplicationContribution).inSingletonScope();
|
||||
|
||||
bind(ContextVariableLabelProvider).toSelf().inSingletonScope();
|
||||
bind(LabelProviderContribution).toService(ContextVariableLabelProvider);
|
||||
bind(ContextFileVariableLabelProvider).toSelf().inSingletonScope();
|
||||
bind(LabelProviderContribution).toService(ContextFileVariableLabelProvider);
|
||||
|
||||
bind(ChangeSetFileService).toSelf().inSingletonScope();
|
||||
bind(ChangeSetFileElementFactory).toFactory(ctx => (args: ChangeSetElementArgs) => {
|
||||
const container = ctx.container.createChild();
|
||||
container.bind(ChangeSetElementArgs).toConstantValue(args);
|
||||
container.bind(ChangeSetFileElement).toSelf().inSingletonScope();
|
||||
return container.get(ChangeSetFileElement);
|
||||
});
|
||||
|
||||
bind(ChangeSetDecoratorService).toSelf().inSingletonScope();
|
||||
bind(FrontendApplicationContribution).toService(ChangeSetDecoratorService);
|
||||
bindContributionProvider(bind, ChangeSetDecorator);
|
||||
bind(ToolCallChatResponseContentFactory).toSelf().inSingletonScope();
|
||||
bind(AIVariableContribution).to(FileChatVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(ContextSummaryVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(ContextDetailsVariableContribution).inSingletonScope();
|
||||
bind(AIVariableContribution).to(ChangeSetVariableContribution).inSingletonScope();
|
||||
|
||||
bind(ChatSessionSummaryAgent).toSelf().inSingletonScope();
|
||||
bind(Agent).toService(ChatSessionSummaryAgent);
|
||||
|
||||
bind(TaskContextVariableContribution).toSelf().inSingletonScope();
|
||||
bind(AIVariableContribution).toService(TaskContextVariableContribution);
|
||||
bind(TaskContextVariableLabelProvider).toSelf().inSingletonScope();
|
||||
bind(LabelProviderContribution).toService(TaskContextVariableLabelProvider);
|
||||
|
||||
bind(ImageContextVariableContribution).toSelf().inSingletonScope();
|
||||
bind(AIVariableContribution).toService(ImageContextVariableContribution);
|
||||
bind(LabelProviderContribution).toService(ImageContextVariableContribution);
|
||||
|
||||
bind(TaskContextService).toSelf().inSingletonScope();
|
||||
bind(InMemoryTaskContextStorage).toSelf().inSingletonScope();
|
||||
bind(TaskContextStorageService).toService(InMemoryTaskContextStorage);
|
||||
bind(AIChatFrontendContribution).toSelf().inSingletonScope();
|
||||
bind(CommandContribution).toService(AIChatFrontendContribution);
|
||||
|
||||
bindToolProvider(AgentDelegationTool, bind);
|
||||
});
|
||||
72
packages/ai-chat/src/browser/change-set-decorator-service.ts
Normal file
72
packages/ai-chat/src/browser/change-set-decorator-service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ContributionProvider, Emitter, type Event } from '@theia/core';
|
||||
import { type FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import debounce = require('@theia/core/shared/lodash.debounce');
|
||||
import type { ChangeSetDecoration, ChangeSetElement } from '../common';
|
||||
|
||||
/**
|
||||
* A decorator for a change set element.
|
||||
* It allows to add additional information to the element, such as icons.
|
||||
*/
|
||||
export const ChangeSetDecorator = Symbol('ChangeSetDecorator');
|
||||
export interface ChangeSetDecorator {
|
||||
readonly id: string;
|
||||
|
||||
readonly onDidChangeDecorations: Event<void>;
|
||||
|
||||
decorate(element: ChangeSetElement): ChangeSetDecoration | undefined;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetDecoratorService implements FrontendApplicationContribution {
|
||||
|
||||
protected readonly onDidChangeDecorationsEmitter = new Emitter<void>();
|
||||
readonly onDidChangeDecorations = this.onDidChangeDecorationsEmitter.event;
|
||||
|
||||
@inject(ContributionProvider) @named(ChangeSetDecorator)
|
||||
protected readonly contributions: ContributionProvider<ChangeSetDecorator>;
|
||||
|
||||
initialize(): void {
|
||||
this.contributions.getContributions().map(decorator => decorator.onDidChangeDecorations(this.fireDidChangeDecorations));
|
||||
}
|
||||
|
||||
protected readonly fireDidChangeDecorations = debounce(() => {
|
||||
this.onDidChangeDecorationsEmitter.fire(undefined);
|
||||
}, 150);
|
||||
|
||||
getDecorations(element: ChangeSetElement): ChangeSetDecoration[] {
|
||||
const decorators = this.contributions.getContributions();
|
||||
const decorations: ChangeSetDecoration[] = [];
|
||||
for (const decorator of decorators) {
|
||||
const decoration = decorator.decorate(element);
|
||||
if (decoration) {
|
||||
decorations.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
decorations.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
|
||||
return decorations;
|
||||
}
|
||||
|
||||
getAdditionalInfoSuffixIcon(element: ChangeSetElement): string[] | undefined {
|
||||
const decorations = this.getDecorations(element);
|
||||
return decorations.find(d => d.additionalInfoSuffixIcon)?.additionalInfoSuffixIcon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { URI } from '@theia/core';
|
||||
import { ChangeSetElement } from '../common/change-set';
|
||||
import { ChangeSetElementDeserializerContribution, ChangeSetElementDeserializerRegistry, ChangeSetDeserializationContext } from '../common/change-set-element-deserializer';
|
||||
import { SerializableChangeSetElement, SerializableChangeSetFileElementData } from '../common/chat-model-serialization';
|
||||
import { ChangeSetElementArgs, ChangeSetFileElementFactory } from './change-set-file-element';
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetFileElementDeserializerContribution implements ChangeSetElementDeserializerContribution {
|
||||
|
||||
@inject(ChangeSetFileElementFactory)
|
||||
protected readonly fileElementFactory: ChangeSetFileElementFactory;
|
||||
|
||||
registerDeserializers(registry: ChangeSetElementDeserializerRegistry): void {
|
||||
registry.register({
|
||||
kind: 'file',
|
||||
deserialize: async (serialized: SerializableChangeSetElement, context: ChangeSetDeserializationContext): Promise<ChangeSetElement> => {
|
||||
const fileData = serialized.data as SerializableChangeSetFileElementData | undefined;
|
||||
|
||||
// Create ChangeSetElementArgs with all the necessary data
|
||||
const args: ChangeSetElementArgs = {
|
||||
uri: new URI(serialized.uri),
|
||||
chatSessionId: context.chatSessionId,
|
||||
requestId: context.requestId,
|
||||
name: serialized.name,
|
||||
icon: serialized.icon,
|
||||
additionalInfo: serialized.additionalInfo,
|
||||
state: serialized.state,
|
||||
type: serialized.type,
|
||||
data: serialized.data,
|
||||
targetState: fileData?.targetState,
|
||||
originalState: fileData?.originalState,
|
||||
replacements: fileData?.replacements
|
||||
};
|
||||
|
||||
// Create the element using the factory
|
||||
const element = this.fileElementFactory(args);
|
||||
|
||||
// Ensure it's initialized before returning
|
||||
await element.ensureInitialized();
|
||||
|
||||
return element;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
489
packages/ai-chat/src/browser/change-set-file-element.ts
Normal file
489
packages/ai-chat/src/browser/change-set-file-element.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ConfigurableInMemoryResources, ConfigurableMutableReferenceResource } from '@theia/ai-core';
|
||||
import { CancellationToken, DisposableCollection, Emitter, nls, URI } from '@theia/core';
|
||||
import { ConfirmDialog } from '@theia/core/lib/browser';
|
||||
import { Replacement } from '@theia/core/lib/common/content-replacer';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { EditorPreferences } from '@theia/editor/lib/common/editor-preferences';
|
||||
import { FileSystemPreferences } from '@theia/filesystem/lib/common';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { IReference } from '@theia/monaco-editor-core/esm/vs/base/common/lifecycle';
|
||||
import { TrimTrailingWhitespaceCommand } from '@theia/monaco-editor-core/esm/vs/editor/common/commands/trimTrailingWhitespaceCommand';
|
||||
import { Selection } from '@theia/monaco-editor-core/esm/vs/editor/common/core/selection';
|
||||
import { CommandExecutor } from '@theia/monaco-editor-core/esm/vs/editor/common/cursor/cursor';
|
||||
import { formatDocumentWithSelectedProvider, FormattingMode } from '@theia/monaco-editor-core/esm/vs/editor/contrib/format/browser/format';
|
||||
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
|
||||
import { IInstantiationService } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation';
|
||||
import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
|
||||
import { insertFinalNewline } from '@theia/monaco/lib/browser/monaco-utilities';
|
||||
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
|
||||
import { ChangeSetElement } from '../common';
|
||||
import { SerializableChangeSetElement } from '../common/chat-model-serialization';
|
||||
import { createChangeSetFileUri } from './change-set-file-resource';
|
||||
import { ChangeSetFileService } from './change-set-file-service';
|
||||
import { Deferred } from '@theia/core/lib/common/promise-util';
|
||||
import { MonacoCodeActionService } from '@theia/monaco/lib/browser';
|
||||
|
||||
export const ChangeSetFileElementFactory = Symbol('ChangeSetFileElementFactory');
|
||||
export type ChangeSetFileElementFactory = (elementProps: ChangeSetElementArgs) => ChangeSetFileElement;
|
||||
type ChangeSetElementState = ChangeSetElement['state'];
|
||||
|
||||
export const ChangeSetElementArgs = Symbol('ChangeSetElementArgs');
|
||||
export interface ChangeSetElementArgs extends Partial<ChangeSetElement> {
|
||||
/** The URI of the element, expected to be unique within the same change set. */
|
||||
uri: URI;
|
||||
/** The id of the chat session containing this change set element. */
|
||||
chatSessionId: string;
|
||||
/** The id of the request with which this change set element is associated. */
|
||||
requestId: string;
|
||||
/**
|
||||
* The state of the file after the changes have been applied.
|
||||
* If `undefined`, there is no change.
|
||||
*/
|
||||
targetState?: string;
|
||||
/**
|
||||
* The state before the change has been applied. If it is specified, we don't care
|
||||
* about the state of the original file on disk but just use the specified `originalState`.
|
||||
* If it isn't specified, we'll derived and observe the state from the file system.
|
||||
*/
|
||||
originalState?: string;
|
||||
/**
|
||||
* An array of replacements used to create the new content for the targetState.
|
||||
* This is only available if the agent was able to provide replacements and we were able to apply them.
|
||||
*/
|
||||
replacements?: Replacement[];
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetFileElement implements ChangeSetElement {
|
||||
|
||||
static toReadOnlyUri(baseUri: URI, sessionId: string): URI {
|
||||
return baseUri.withScheme('change-set-immutable').withAuthority(sessionId);
|
||||
}
|
||||
|
||||
@inject(ChangeSetElementArgs)
|
||||
protected readonly elementProps: ChangeSetElementArgs;
|
||||
|
||||
@inject(ChangeSetFileService)
|
||||
protected readonly changeSetFileService: ChangeSetFileService;
|
||||
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(ConfigurableInMemoryResources)
|
||||
protected readonly inMemoryResources: ConfigurableInMemoryResources;
|
||||
|
||||
@inject(MonacoTextModelService)
|
||||
protected readonly monacoTextModelService: MonacoTextModelService;
|
||||
|
||||
@inject(EditorPreferences)
|
||||
protected readonly editorPreferences: EditorPreferences;
|
||||
|
||||
@inject(FileSystemPreferences)
|
||||
protected readonly fileSystemPreferences: FileSystemPreferences;
|
||||
|
||||
@inject(MonacoCodeActionService)
|
||||
protected readonly codeActionService: MonacoCodeActionService;
|
||||
|
||||
protected readonly toDispose = new DisposableCollection();
|
||||
protected _state: ChangeSetElementState;
|
||||
|
||||
private _originalContent: string | undefined;
|
||||
protected _initialized = false;
|
||||
protected _initializationPromise: Promise<void> | undefined;
|
||||
protected _targetStateWithCodeActions: string | undefined;
|
||||
protected codeActionDeferred?: Deferred<string>;
|
||||
|
||||
protected readonly onDidChangeEmitter = new Emitter<void>();
|
||||
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
protected _readOnlyResource?: ConfigurableMutableReferenceResource;
|
||||
protected _changeResource?: ConfigurableMutableReferenceResource;
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this._initializationPromise = this.initializeAsync();
|
||||
this.toDispose.push(this.onDidChangeEmitter);
|
||||
}
|
||||
|
||||
protected async initializeAsync(): Promise<void> {
|
||||
await this.obtainOriginalContent();
|
||||
this.listenForOriginalFileChanges();
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the element is fully initialized before proceeding.
|
||||
* This includes loading the original content from the file system.
|
||||
*/
|
||||
async ensureInitialized(): Promise<void> {
|
||||
await this._initializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the element has been fully initialized.
|
||||
*/
|
||||
get isInitialized(): boolean {
|
||||
return this._initialized;
|
||||
}
|
||||
|
||||
protected async obtainOriginalContent(): Promise<void> {
|
||||
this._originalContent = this.elementProps.originalState ?? await this.changeSetFileService.read(this.uri);
|
||||
if (this._readOnlyResource) {
|
||||
this.readOnlyResource.update({ contents: this._originalContent ?? '' });
|
||||
}
|
||||
}
|
||||
|
||||
protected getInMemoryUri(uri: URI): ConfigurableMutableReferenceResource {
|
||||
try { return this.inMemoryResources.resolve(uri); } catch { return this.inMemoryResources.add(uri, { contents: '' }); }
|
||||
}
|
||||
|
||||
protected listenForOriginalFileChanges(): void {
|
||||
if (this.elementProps.originalState) {
|
||||
// if we have an original state, we are not interested in the original file on disk but always use `originalState`
|
||||
return;
|
||||
}
|
||||
this.toDispose.push(this.fileService.onDidFilesChange(async event => {
|
||||
if (!event.contains(this.uri)) { return; }
|
||||
if (!this._initialized && this._initializationPromise) {
|
||||
// make sure we are initialized
|
||||
await this._initializationPromise;
|
||||
}
|
||||
// If we are applied, the tricky thing becomes the question what to revert to; otherwise, what to apply.
|
||||
const newContent = await this.changeSetFileService.read(this.uri).catch(() => '');
|
||||
this.readOnlyResource.update({ contents: newContent });
|
||||
if (newContent === this._originalContent) {
|
||||
this.state = 'pending';
|
||||
} else if (newContent === this.targetState) {
|
||||
this.state = 'applied';
|
||||
} else {
|
||||
this.state = 'stale';
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
get uri(): URI {
|
||||
return this.elementProps.uri;
|
||||
}
|
||||
|
||||
protected get readOnlyResource(): ConfigurableMutableReferenceResource {
|
||||
if (!this._readOnlyResource) {
|
||||
this._readOnlyResource = this.getInMemoryUri(ChangeSetFileElement.toReadOnlyUri(this.uri, this.elementProps.chatSessionId));
|
||||
this._readOnlyResource.update({
|
||||
autosaveable: false,
|
||||
readOnly: true,
|
||||
contents: this._originalContent ?? ''
|
||||
});
|
||||
this.toDispose.push(this._readOnlyResource);
|
||||
|
||||
// If not yet initialized, update the resource once initialization completes
|
||||
if (!this._initialized) {
|
||||
this._initializationPromise?.then(() => {
|
||||
this._readOnlyResource?.update({ contents: this._originalContent ?? '' });
|
||||
});
|
||||
}
|
||||
}
|
||||
return this._readOnlyResource;
|
||||
}
|
||||
|
||||
get readOnlyUri(): URI {
|
||||
return this.readOnlyResource.uri;
|
||||
}
|
||||
|
||||
protected get changeResource(): ConfigurableMutableReferenceResource {
|
||||
if (!this._changeResource) {
|
||||
this._changeResource = this.getInMemoryUri(createChangeSetFileUri(this.elementProps.chatSessionId, this.uri));
|
||||
this._changeResource.update({ autosaveable: false, contents: this.targetState });
|
||||
this.applyCodeActionsToTargetState();
|
||||
this.toDispose.push(this._changeResource);
|
||||
}
|
||||
return this._changeResource;
|
||||
}
|
||||
|
||||
get changedUri(): URI {
|
||||
return this.changeResource.uri;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.elementProps.name ?? this.changeSetFileService.getName(this.uri);
|
||||
}
|
||||
|
||||
get icon(): string | undefined {
|
||||
return this.elementProps.icon ?? this.changeSetFileService.getIcon(this.uri);
|
||||
}
|
||||
|
||||
get additionalInfo(): string | undefined {
|
||||
return this.changeSetFileService.getAdditionalInfo(this.uri);
|
||||
}
|
||||
|
||||
get state(): ChangeSetElementState {
|
||||
return this._state ?? this.elementProps.state;
|
||||
}
|
||||
|
||||
protected set state(value: ChangeSetElementState) {
|
||||
if (this._state !== value) {
|
||||
this._state = value;
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
get replacements(): Replacement[] | undefined {
|
||||
return this.elementProps.replacements;
|
||||
}
|
||||
|
||||
get type(): 'add' | 'modify' | 'delete' | undefined {
|
||||
return this.elementProps.type;
|
||||
}
|
||||
|
||||
get data(): { [key: string]: unknown; } | undefined {
|
||||
return this.elementProps.data;
|
||||
};
|
||||
|
||||
get originalContent(): string | undefined {
|
||||
if (!this._initialized && this._initializationPromise) {
|
||||
console.warn('Accessing originalContent before initialization is complete. Consider using async methods.');
|
||||
}
|
||||
return this._originalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the original content of the file asynchronously.
|
||||
* Ensures initialization is complete before returning the content.
|
||||
*/
|
||||
async getOriginalContent(): Promise<string | undefined> {
|
||||
await this.ensureInitialized();
|
||||
return this._originalContent;
|
||||
}
|
||||
|
||||
get targetState(): string {
|
||||
return this._targetStateWithCodeActions ?? this.elementProps.targetState ?? '';
|
||||
}
|
||||
|
||||
get originalTargetState(): string {
|
||||
return this.elementProps.targetState ?? '';
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
this.changeSetFileService.open(this);
|
||||
}
|
||||
|
||||
async openChange(): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
this.changeSetFileService.openDiff(
|
||||
this.readOnlyUri,
|
||||
this.changedUri
|
||||
);
|
||||
}
|
||||
|
||||
async apply(contents?: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
if (!await this.confirm('Apply')) { return; }
|
||||
|
||||
if (this.type === 'delete') {
|
||||
await this.changeSetFileService.delete(this.uri);
|
||||
this.state = 'applied';
|
||||
this.changeSetFileService.closeDiff(this.readOnlyUri);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Monaco model for the base file URI and apply changes
|
||||
await this.applyChangesWithMonaco(contents);
|
||||
this.changeSetFileService.closeDiff(this.readOnlyUri);
|
||||
}
|
||||
|
||||
async writeChanges(contents?: string): Promise<void> {
|
||||
await this.changeSetFileService.writeFrom(this.changedUri, this.uri, contents ?? this.targetState);
|
||||
this.state = 'applied';
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies changes using Monaco utilities, including loading the model for the base file URI,
|
||||
* setting the value to the intended state, and running code actions on save.
|
||||
*/
|
||||
protected async applyChangesWithMonaco(contents?: string): Promise<void> {
|
||||
let modelReference: IReference<MonacoEditorModel> | undefined;
|
||||
|
||||
try {
|
||||
modelReference = await this.monacoTextModelService.createModelReference(this.uri);
|
||||
const model = modelReference.object;
|
||||
const targetContent = contents ?? this.targetState;
|
||||
model.textEditorModel.setValue(targetContent);
|
||||
|
||||
const languageId = model.languageId;
|
||||
const uriStr = this.uri.toString();
|
||||
|
||||
await this.codeActionService.applyOnSaveCodeActions(model.textEditorModel, languageId, uriStr, CancellationToken.None);
|
||||
await this.applyFormatting(model, languageId, uriStr);
|
||||
|
||||
await model.save();
|
||||
this.state = 'applied';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to apply changes with Monaco:', error);
|
||||
await this.writeChanges(contents);
|
||||
} finally {
|
||||
modelReference?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
protected applyCodeActionsToTargetState(): Promise<string> {
|
||||
if (!this.codeActionDeferred) {
|
||||
this.codeActionDeferred = new Deferred();
|
||||
this.codeActionDeferred.resolve(this.doApplyCodeActionsToTargetState());
|
||||
}
|
||||
return this.codeActionDeferred.promise;
|
||||
}
|
||||
|
||||
protected async doApplyCodeActionsToTargetState(): Promise<string> {
|
||||
const targetState = this.originalTargetState;
|
||||
if (!targetState) {
|
||||
this._targetStateWithCodeActions = '';
|
||||
return this._targetStateWithCodeActions;
|
||||
}
|
||||
|
||||
let tempResource: ConfigurableMutableReferenceResource | undefined;
|
||||
let tempModel: IReference<MonacoEditorModel> | undefined;
|
||||
try {
|
||||
// Create a temporary model to apply code actions
|
||||
const tempUri = URI.fromComponents({
|
||||
scheme: 'untitled',
|
||||
path: this.uri.path.toString(),
|
||||
authority: `changeset-${this.elementProps.chatSessionId}`,
|
||||
query: '',
|
||||
fragment: ''
|
||||
});
|
||||
tempResource = this.getInMemoryUri(tempUri);
|
||||
tempResource.update({ contents: this.targetState });
|
||||
tempModel = await this.monacoTextModelService.createModelReference(tempUri);
|
||||
tempModel.object.suppressOpenEditorWhenDirty = true;
|
||||
tempModel.object.textEditorModel.setValue(this.targetState);
|
||||
|
||||
const languageId = tempModel.object.languageId;
|
||||
const uriStr = this.uri.toString();
|
||||
|
||||
await this.codeActionService.applyOnSaveCodeActions(tempModel.object.textEditorModel, languageId, uriStr, CancellationToken.None);
|
||||
|
||||
// Apply formatting and other editor preferences
|
||||
await this.applyFormatting(tempModel.object, languageId, uriStr);
|
||||
|
||||
this._targetStateWithCodeActions = tempModel.object.textEditorModel.getValue();
|
||||
if (this._changeResource?.contents === this.elementProps.targetState) {
|
||||
this._changeResource?.update({ contents: this.targetState });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply code actions to target state:', error);
|
||||
this._targetStateWithCodeActions = targetState;
|
||||
} finally {
|
||||
tempModel?.dispose();
|
||||
tempResource?.dispose();
|
||||
}
|
||||
|
||||
return this.targetState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies formatting preferences like format on save, trim trailing whitespace, and insert final newline.
|
||||
*/
|
||||
protected async applyFormatting(model: MonacoEditorModel, languageId: string, uriStr: string): Promise<void> {
|
||||
try {
|
||||
const formatOnSave = this.editorPreferences.get({ preferenceName: 'editor.formatOnSave', overrideIdentifier: languageId }, undefined, uriStr);
|
||||
if (formatOnSave) {
|
||||
const instantiation = StandaloneServices.get(IInstantiationService);
|
||||
await instantiation.invokeFunction(
|
||||
formatDocumentWithSelectedProvider,
|
||||
model.textEditorModel,
|
||||
FormattingMode.Explicit,
|
||||
{ report(): void { } },
|
||||
CancellationToken.None, true
|
||||
);
|
||||
}
|
||||
|
||||
const trimTrailingWhitespace = this.fileSystemPreferences.get({ preferenceName: 'files.trimTrailingWhitespace', overrideIdentifier: languageId }, undefined, uriStr);
|
||||
if (trimTrailingWhitespace) {
|
||||
const ttws = new TrimTrailingWhitespaceCommand(new Selection(1, 1, 1, 1), [], false);
|
||||
CommandExecutor.executeCommands(model.textEditorModel, [], [ttws]);
|
||||
}
|
||||
|
||||
const shouldInsertFinalNewline = this.fileSystemPreferences.get({ preferenceName: 'files.insertFinalNewline', overrideIdentifier: languageId }, undefined, uriStr);
|
||||
if (shouldInsertFinalNewline) {
|
||||
insertFinalNewline(model);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to apply formatting:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onShow(): void {
|
||||
this.changeResource.update({
|
||||
contents: this.targetState,
|
||||
onSave: async content => {
|
||||
// Use Monaco utilities when saving from the change resource
|
||||
await this.applyChangesWithMonaco(content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async revert(): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
if (!await this.confirm('Revert')) { return; }
|
||||
this.state = 'pending';
|
||||
if (this.type === 'add') {
|
||||
await this.changeSetFileService.delete(this.uri);
|
||||
} else if (this._originalContent) {
|
||||
await this.changeSetFileService.write(this.uri, this._originalContent);
|
||||
}
|
||||
}
|
||||
|
||||
async confirm(verb: 'Apply' | 'Revert'): Promise<boolean> {
|
||||
if (this._state !== 'stale') { return true; }
|
||||
await this.openChange();
|
||||
const answer = await new ConfirmDialog({
|
||||
title: verb === 'Apply'
|
||||
? nls.localize('theia/ai/chat/applySuggestion', 'Apply suggestion')
|
||||
: nls.localize('theia/ai/chat/revertSuggestion', 'Revert suggestion'),
|
||||
msg: verb === 'Apply'
|
||||
? nls.localize('theia/ai/chat/confirmApplySuggestion',
|
||||
'The file {0} has changed since this suggestion was created. Are you certain you wish to apply the change?', this.uri.path.toString())
|
||||
: nls.localize('theia/ai/chat/confirmRevertSuggestion',
|
||||
'The file {0} has changed since this suggestion was created. Are you certain you wish to revert the change?', this.uri.path.toString())
|
||||
}).open(true);
|
||||
return !!answer;
|
||||
}
|
||||
|
||||
toSerializable(): SerializableChangeSetElement {
|
||||
return {
|
||||
kind: 'file',
|
||||
uri: this.uri.toString(),
|
||||
name: this.name,
|
||||
icon: this.icon,
|
||||
additionalInfo: this.additionalInfo,
|
||||
state: this.state,
|
||||
type: this.type,
|
||||
data: {
|
||||
...this.data,
|
||||
targetState: this.elementProps.targetState,
|
||||
originalState: this._originalContent,
|
||||
replacements: this.replacements
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
}
|
||||
23
packages/ai-chat/src/browser/change-set-file-resource.ts
Normal file
23
packages/ai-chat/src/browser/change-set-file-resource.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { URI } from '@theia/core';
|
||||
|
||||
export const CHANGE_SET_FILE_RESOURCE_SCHEME = 'changeset-file';
|
||||
|
||||
export function createChangeSetFileUri(chatSessionId: string, elementUri: URI): URI {
|
||||
return elementUri.withScheme(CHANGE_SET_FILE_RESOURCE_SCHEME).withAuthority(chatSessionId);
|
||||
}
|
||||
158
packages/ai-chat/src/browser/change-set-file-service.ts
Normal file
158
packages/ai-chat/src/browser/change-set-file-service.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ILogger, nls, URI } from '@theia/core';
|
||||
import { ApplicationShell, DiffUris, LabelProvider, NavigatableWidget, OpenerService, open } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { EditorManager } from '@theia/editor/lib/browser';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
|
||||
import { ChangeSetFileElement } from './change-set-file-element';
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetFileService {
|
||||
@inject(ILogger)
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly wsService: WorkspaceService;
|
||||
|
||||
@inject(LabelProvider)
|
||||
protected readonly labelProvider: LabelProvider;
|
||||
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
|
||||
@inject(EditorManager)
|
||||
protected readonly editorManager: EditorManager;
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly shell: ApplicationShell;
|
||||
|
||||
@inject(MonacoWorkspace)
|
||||
protected readonly monacoWorkspace: MonacoWorkspace;
|
||||
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
async read(uri: URI): Promise<string | undefined> {
|
||||
const exists = await this.fileService.exists(uri);
|
||||
if (!exists) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const document = this.monacoWorkspace.getTextDocument(uri.toString());
|
||||
if (document) {
|
||||
return document.getText();
|
||||
}
|
||||
return (await this.fileService.readFile(uri)).value.toString();
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to read original content of change set file element.', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getName(uri: URI): string {
|
||||
return this.labelProvider.getName(uri);
|
||||
}
|
||||
|
||||
getIcon(uri: URI): string | undefined {
|
||||
return this.labelProvider.getIcon(uri);
|
||||
}
|
||||
|
||||
getAdditionalInfo(uri: URI): string | undefined {
|
||||
const wsUri = this.wsService.getWorkspaceRootUri(uri);
|
||||
if (wsUri) {
|
||||
const wsRelative = wsUri.relative(uri);
|
||||
if (wsRelative?.hasDir) {
|
||||
return wsRelative.dir.toString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return this.labelProvider.getLongName(uri.parent);
|
||||
}
|
||||
|
||||
async open(element: ChangeSetFileElement): Promise<void> {
|
||||
const exists = await this.fileService.exists(element.uri);
|
||||
if (exists) {
|
||||
await open(this.openerService, element.uri);
|
||||
return;
|
||||
}
|
||||
await this.editorManager.open(element.changedUri, {
|
||||
mode: 'reveal'
|
||||
});
|
||||
}
|
||||
|
||||
async openDiff(originalUri: URI, suggestedUri: URI): Promise<void> {
|
||||
const diffUri = this.getDiffUri(originalUri, suggestedUri);
|
||||
open(this.openerService, diffUri);
|
||||
}
|
||||
|
||||
protected getDiffUri(originalUri: URI, suggestedUri: URI): URI {
|
||||
return DiffUris.encode(originalUri, suggestedUri,
|
||||
nls.localize('theia/ai/chat/changeSetFileDiffUriLabel', 'AI Changes: {0}', this.labelProvider.getName(originalUri)),
|
||||
);
|
||||
}
|
||||
|
||||
async delete(uri: URI): Promise<void> {
|
||||
const exists = await this.fileService.exists(uri);
|
||||
if (exists) {
|
||||
await this.fileService.delete(uri);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if there was a document available to save for the specified URI. */
|
||||
async trySave(suggestedUri: URI): Promise<boolean> {
|
||||
const openModel = this.monacoWorkspace.getTextDocument(suggestedUri.toString());
|
||||
if (openModel) {
|
||||
await openModel.save();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async writeFrom(from: URI, to: URI, fallbackContent: string): Promise<void> {
|
||||
const authoritativeContent = this.monacoWorkspace.getTextDocument(from.toString())?.getText() ?? fallbackContent;
|
||||
await this.write(to, authoritativeContent);
|
||||
}
|
||||
|
||||
async write(uri: URI, text: string): Promise<void> {
|
||||
const document = this.monacoWorkspace.getTextDocument(uri.toString());
|
||||
if (document) {
|
||||
await this.monacoWorkspace.applyBackgroundEdit(document, [{
|
||||
range: document.textEditorModel.getFullModelRange(),
|
||||
text
|
||||
}], () => true);
|
||||
} else {
|
||||
await this.fileService.write(uri, text);
|
||||
}
|
||||
}
|
||||
|
||||
closeDiffsForSession(sessionId: string, except?: URI[]): void {
|
||||
const openEditors = this.shell.widgets.filter(widget => {
|
||||
const uri = NavigatableWidget.getUri(widget);
|
||||
return uri && uri.authority === sessionId && !except?.some(candidate => candidate.path.toString() === uri.path.toString());
|
||||
});
|
||||
openEditors.forEach(editor => editor.close());
|
||||
}
|
||||
|
||||
closeDiff(uri: URI): void {
|
||||
const openEditors = this.shell.widgets.filter(widget => NavigatableWidget.getUri(widget)?.isEqual(uri));
|
||||
openEditors.forEach(editor => editor.close());
|
||||
}
|
||||
}
|
||||
62
packages/ai-chat/src/browser/change-set-variable.ts
Normal file
62
packages/ai-chat/src/browser/change-set-variable.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from '@theia/ai-core';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { CHANGE_SET_SUMMARY_VARIABLE_ID, ChatSessionContext } from '../common';
|
||||
|
||||
export const CHANGE_SET_SUMMARY_VARIABLE: AIVariable = {
|
||||
id: CHANGE_SET_SUMMARY_VARIABLE_ID,
|
||||
description: nls.localize('theia/ai/core/changeSetSummaryVariable/description', 'Provides a summary of the files in a change set and their contents.'),
|
||||
|
||||
name: CHANGE_SET_SUMMARY_VARIABLE_ID,
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(CHANGE_SET_SUMMARY_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
|
||||
return request.variable.name === CHANGE_SET_SUMMARY_VARIABLE.name ? 50 : 0;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
if (!ChatSessionContext.is(context) || request.variable.name !== CHANGE_SET_SUMMARY_VARIABLE.name) { return undefined; }
|
||||
if (!context.model.changeSet.getElements().length) {
|
||||
return {
|
||||
variable: CHANGE_SET_SUMMARY_VARIABLE,
|
||||
value: ''
|
||||
};
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
context.model.changeSet.getElements().map(async element => `- file: ${await this.workspaceService.getWorkspaceRelativePath(element.uri)}, status: ${element.state}`)
|
||||
);
|
||||
return {
|
||||
variable: CHANGE_SET_SUMMARY_VARIABLE,
|
||||
value: `## Previously Proposed Changes
|
||||
You have previously proposed changes for the following files. Some suggestions may have been accepted by the user, while others may still be pending.
|
||||
${entries.join('\n')}
|
||||
`
|
||||
};
|
||||
}
|
||||
}
|
||||
705
packages/ai-chat/src/browser/chat-session-store-impl.spec.ts
Normal file
705
packages/ai-chat/src/browser/chat-session-store-impl.spec.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
let disableJSDOM = enableJSDOM();
|
||||
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { Container } from '@theia/core/shared/inversify';
|
||||
import { ChatSessionStoreImpl } from './chat-session-store-impl';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService, WorkspaceMetadataStorageService, WorkspaceMetadataStore } from '@theia/workspace/lib/browser';
|
||||
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
|
||||
import { PreferenceService } from '@theia/core/lib/common';
|
||||
import { StorageService } from '@theia/core/lib/browser';
|
||||
import { ILogger } from '@theia/core/lib/common/logger';
|
||||
import { URI, Emitter } from '@theia/core';
|
||||
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
||||
import { ChatSessionIndex, ChatSessionMetadata } from '../common/chat-session-store';
|
||||
import {
|
||||
PERSISTED_SESSION_LIMIT_PREF,
|
||||
SESSION_STORAGE_PREF,
|
||||
SessionStorageScope
|
||||
} from '../common/ai-chat-preferences';
|
||||
import { ChatAgentLocation } from '../common/chat-agents';
|
||||
import { FileStat } from '@theia/filesystem/lib/common/files';
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
describe('ChatSessionStoreImpl', () => {
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
let container: Container;
|
||||
let chatSessionStore: ChatSessionStoreImpl;
|
||||
let mockFileService: sinon.SinonStubbedInstance<FileService>;
|
||||
let mockPreferenceService: sinon.SinonStubbedInstance<PreferenceService>;
|
||||
let mockEnvServer: sinon.SinonStubbedInstance<EnvVariablesServer>;
|
||||
let mockWorkspaceService: {
|
||||
tryGetRoots: () => FileStat[];
|
||||
};
|
||||
let deletedFiles: string[];
|
||||
let preferenceChangeCallback: ((event: { preferenceName: string }) => void) | undefined;
|
||||
|
||||
// Use obviously fake paths that will not exist on real systems to prevent any accidental
|
||||
// interaction with actual user data if mocking were to misconfigured
|
||||
const STORAGE_ROOT = 'file:///__test__/mock-config/chatSessions';
|
||||
const WORKSPACE_METADATA_STORAGE = 'file:///__test__/mock-config/workspace-metadata/test-uuid/chatSessions';
|
||||
const WORKSPACE_ROOT = 'file:///__test__/mock-workspace/my-project';
|
||||
const GLOBAL_CONFIG_DIR = 'file:///__test__/mock-config';
|
||||
const DEFAULT_STORAGE_SCOPE: SessionStorageScope = 'workspace';
|
||||
|
||||
function createMockSessionMetadata(id: string, saveDate: number): ChatSessionMetadata {
|
||||
return {
|
||||
sessionId: id,
|
||||
title: `Session ${id}`,
|
||||
saveDate,
|
||||
location: ChatAgentLocation.Panel
|
||||
};
|
||||
}
|
||||
|
||||
function createMockIndex(sessions: ChatSessionMetadata[]): ChatSessionIndex {
|
||||
const index: ChatSessionIndex = {};
|
||||
for (const session of sessions) {
|
||||
index[session.sessionId] = session;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
disableJSDOM = enableJSDOM();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
disableJSDOM();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox();
|
||||
deletedFiles = [];
|
||||
preferenceChangeCallback = undefined;
|
||||
|
||||
container = new Container();
|
||||
|
||||
mockFileService = {
|
||||
readFile: sandbox.stub(),
|
||||
writeFile: sandbox.stub().resolves(),
|
||||
delete: sandbox.stub().callsFake(async (uri: URI) => {
|
||||
deletedFiles.push(uri.toString());
|
||||
}),
|
||||
createFolder: sandbox.stub().resolves(),
|
||||
exists: sandbox.stub().resolves(true),
|
||||
copy: sandbox.stub().resolves(),
|
||||
resolve: sandbox.stub().resolves({ children: [] })
|
||||
} as unknown as sinon.SinonStubbedInstance<FileService>;
|
||||
|
||||
mockPreferenceService = {
|
||||
ready: Promise.resolve(),
|
||||
get: sandbox.stub(),
|
||||
onPreferenceChanged: sandbox.stub().callsFake((callback: (event: { preferenceName: string }) => void) => {
|
||||
preferenceChangeCallback = callback;
|
||||
return { dispose: () => { preferenceChangeCallback = undefined; } };
|
||||
})
|
||||
} as unknown as sinon.SinonStubbedInstance<PreferenceService>;
|
||||
|
||||
mockEnvServer = {
|
||||
getConfigDirUri: sandbox.stub().resolves(GLOBAL_CONFIG_DIR)
|
||||
} as unknown as sinon.SinonStubbedInstance<EnvVariablesServer>;
|
||||
|
||||
mockWorkspaceService = {
|
||||
tryGetRoots: () => []
|
||||
};
|
||||
const mockStorageService = {} as StorageService;
|
||||
const mockLogger = {
|
||||
debug: sandbox.stub(),
|
||||
info: sandbox.stub(),
|
||||
warn: sandbox.stub(),
|
||||
error: sandbox.stub()
|
||||
} as unknown as ILogger;
|
||||
|
||||
// Create a mock WorkspaceMetadataStore
|
||||
const locationChangeEmitter = new Emitter<URI>();
|
||||
const mockMetadataStore: WorkspaceMetadataStore = {
|
||||
key: 'chatSessions',
|
||||
location: new URI(WORKSPACE_METADATA_STORAGE),
|
||||
onDidChangeLocation: locationChangeEmitter.event,
|
||||
ensureExists: sandbox.stub().resolves(),
|
||||
delete: sandbox.stub().resolves(),
|
||||
dispose: () => locationChangeEmitter.dispose()
|
||||
};
|
||||
|
||||
// Create a mock for WorkspaceMetadataStorageService
|
||||
const mockMetadataStorageService = {
|
||||
getOrCreateStore: sandbox.stub().resolves(mockMetadataStore)
|
||||
};
|
||||
|
||||
container.bind(FileService).toConstantValue(mockFileService as unknown as FileService);
|
||||
container.bind(PreferenceService).toConstantValue(mockPreferenceService as unknown as PreferenceService);
|
||||
container.bind(EnvVariablesServer).toConstantValue(mockEnvServer as unknown as EnvVariablesServer);
|
||||
container.bind(WorkspaceService).toConstantValue(mockWorkspaceService as unknown as WorkspaceService);
|
||||
container.bind(WorkspaceMetadataStorageService).toConstantValue(mockMetadataStorageService as unknown as WorkspaceMetadataStorageService);
|
||||
container.bind(StorageService).toConstantValue(mockStorageService);
|
||||
container.bind('ChatSessionStore').toConstantValue(mockLogger);
|
||||
container.bind(ILogger).toConstantValue(mockLogger).whenTargetNamed('ChatSessionStore');
|
||||
|
||||
container.bind(ChatSessionStoreImpl).toSelf().inSingletonScope();
|
||||
|
||||
chatSessionStore = container.get(ChatSessionStoreImpl);
|
||||
|
||||
// Set up default storage preferences for all tests
|
||||
mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns(DEFAULT_STORAGE_SCOPE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('trimSessions', () => {
|
||||
describe('when persistedSessionLimit is -1 (unlimited)', () => {
|
||||
beforeEach(() => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(-1);
|
||||
});
|
||||
|
||||
it('should not delete any sessions regardless of count', async () => {
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000),
|
||||
createMockSessionMetadata('session-4', 4000),
|
||||
createMockSessionMetadata('session-5', 5000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// Access protected method via any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.be.empty;
|
||||
expect(mockFileService.delete.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should not delete sessions even with 100 sessions', async () => {
|
||||
const sessions = Array.from({ length: 100 }, (_, i) =>
|
||||
createMockSessionMetadata(`session-${i}`, (i + 1) * 1000)
|
||||
);
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when persistedSessionLimit is 0 (no persistence)', () => {
|
||||
beforeEach(() => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(0);
|
||||
});
|
||||
|
||||
it('should delete all sessions and clear index', async () => {
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.have.lengthOf(3);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-3.json`);
|
||||
|
||||
const savedIndexCall = mockFileService.writeFile.lastCall;
|
||||
expect(savedIndexCall).to.not.be.null;
|
||||
const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
|
||||
expect(Object.keys(savedIndex)).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should handle empty index gracefully', async () => {
|
||||
const index: ChatSessionIndex = {};
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.be.empty;
|
||||
});
|
||||
});
|
||||
|
||||
describe('when persistedSessionLimit is positive', () => {
|
||||
it('should not trim when session count is within limit', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(5);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.be.empty;
|
||||
});
|
||||
|
||||
it('should trim oldest sessions when count exceeds limit', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(3);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000),
|
||||
createMockSessionMetadata('session-4', 4000),
|
||||
createMockSessionMetadata('session-5', 5000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.have.lengthOf(2);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`);
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-3.json`);
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-4.json`);
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-5.json`);
|
||||
});
|
||||
|
||||
it('should delete sessions in order of saveDate (oldest first)', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-newest', 5000),
|
||||
createMockSessionMetadata('session-middle', 3000),
|
||||
createMockSessionMetadata('session-oldest', 1000),
|
||||
createMockSessionMetadata('session-second-oldest', 2000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.have.lengthOf(2);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-oldest.json`);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-second-oldest.json`);
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-newest.json`);
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-middle.json`);
|
||||
});
|
||||
|
||||
it('should update index after trimming', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000),
|
||||
createMockSessionMetadata('session-4', 4000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
const savedIndexCall = mockFileService.writeFile.lastCall;
|
||||
expect(savedIndexCall).to.not.be.null;
|
||||
|
||||
const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
|
||||
expect(Object.keys(savedIndex)).to.have.lengthOf(2);
|
||||
expect(savedIndex['session-3']).to.not.be.undefined;
|
||||
expect(savedIndex['session-4']).to.not.be.undefined;
|
||||
expect(savedIndex['session-1']).to.be.undefined;
|
||||
expect(savedIndex['session-2']).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should trim to exactly the limit (42 session bug scenario)', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25);
|
||||
|
||||
const sessions = Array.from({ length: 42 }, (_, i) =>
|
||||
createMockSessionMetadata(`session-${i}`, (i + 1) * 1000)
|
||||
);
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.have.lengthOf(17);
|
||||
|
||||
for (let i = 0; i < 17; i++) {
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-${i}.json`);
|
||||
}
|
||||
|
||||
for (let i = 17; i < 42; i++) {
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-${i}.json`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty session index', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(5);
|
||||
|
||||
const index: ChatSessionIndex = {};
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.be.empty;
|
||||
});
|
||||
|
||||
it('should handle sessions exactly at limit', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(3);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.be.empty;
|
||||
});
|
||||
|
||||
it('should handle limit of 1', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(1);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.have.lengthOf(2);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`);
|
||||
expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`);
|
||||
expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-3.json`);
|
||||
});
|
||||
|
||||
it('should handle file deletion errors gracefully', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-1', 1000),
|
||||
createMockSessionMetadata('session-2', 2000),
|
||||
createMockSessionMetadata('session-3', 3000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
mockFileService.delete.rejects(new Error('File not found'));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
const savedIndexCall = mockFileService.writeFile.lastCall;
|
||||
const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
|
||||
expect(Object.keys(savedIndex)).to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
it('should handle sessions with equal save dates', async () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2);
|
||||
|
||||
const sessions = [
|
||||
createMockSessionMetadata('session-a', 1000),
|
||||
createMockSessionMetadata('session-b', 1000),
|
||||
createMockSessionMetadata('session-c', 2000),
|
||||
createMockSessionMetadata('session-d', 2000)
|
||||
];
|
||||
const index = createMockIndex(sessions);
|
||||
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).trimSessions();
|
||||
|
||||
expect(deletedFiles).to.have.lengthOf(2);
|
||||
|
||||
const savedIndexCall = mockFileService.writeFile.lastCall;
|
||||
const savedIndex = JSON.parse(savedIndexCall.args[1].toString());
|
||||
expect(Object.keys(savedIndex)).to.have.lengthOf(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPersistedSessionLimit', () => {
|
||||
it('should return -1 for unlimited sessions', () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(-1);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (chatSessionStore as any).getPersistedSessionLimit();
|
||||
|
||||
expect(result).to.equal(-1);
|
||||
});
|
||||
|
||||
it('should return 0 for no persistence', () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(0);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (chatSessionStore as any).getPersistedSessionLimit();
|
||||
|
||||
expect(result).to.equal(0);
|
||||
});
|
||||
|
||||
it('should return default value of 25', () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (chatSessionStore as any).getPersistedSessionLimit();
|
||||
|
||||
expect(result).to.equal(25);
|
||||
});
|
||||
|
||||
it('should return custom positive value', () => {
|
||||
mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(100);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = (chatSessionStore as any).getPersistedSessionLimit();
|
||||
|
||||
expect(result).to.equal(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStorageRoot', () => {
|
||||
describe('when scope is workspace', () => {
|
||||
it('should use workspace storage path when workspace is open', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (chatSessionStore as any).resolveStorageRoot();
|
||||
|
||||
expect(result.toString()).to.equal(WORKSPACE_METADATA_STORAGE);
|
||||
});
|
||||
|
||||
it('should use workspace metadata storage', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (chatSessionStore as any).resolveStorageRoot();
|
||||
|
||||
expect(result.toString()).to.equal(WORKSPACE_METADATA_STORAGE);
|
||||
});
|
||||
|
||||
it('should fall back to global storage when no workspace is open', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (chatSessionStore as any).resolveStorageRoot();
|
||||
|
||||
expect(result.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when scope is global', () => {
|
||||
beforeEach(() => {
|
||||
mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global');
|
||||
});
|
||||
|
||||
it('should use global storage path', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (chatSessionStore as any).resolveStorageRoot();
|
||||
|
||||
expect(result.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`);
|
||||
});
|
||||
|
||||
it('should ignore workspace even when open', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (chatSessionStore as any).resolveStorageRoot();
|
||||
|
||||
expect(result.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache invalidation', () => {
|
||||
it('should invalidate cache when storage preference changes scope', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// First call to establish cache
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const firstResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(firstResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE);
|
||||
|
||||
// Change scope to global
|
||||
mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global');
|
||||
|
||||
// Trigger preference change
|
||||
expect(preferenceChangeCallback).to.not.be.undefined;
|
||||
preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF });
|
||||
|
||||
// Next call should resolve new path
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const secondResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(secondResult.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`);
|
||||
});
|
||||
|
||||
it('should invalidate cache when workspace path preference changes', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// First call to establish cache
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const firstResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(firstResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE);
|
||||
|
||||
// Workspace path changes are no longer applicable as we use WorkspaceMetadataStore
|
||||
// Trigger preference change event (scope remains 'workspace')
|
||||
mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('workspace');
|
||||
|
||||
// Trigger preference change
|
||||
expect(preferenceChangeCallback).to.not.be.undefined;
|
||||
preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF });
|
||||
|
||||
// Path should remain the same since WorkspaceMetadataStore manages the location
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const secondResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(secondResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE);
|
||||
});
|
||||
|
||||
it('should maintain same global path since paths are no longer customizable', async () => {
|
||||
mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global');
|
||||
|
||||
// First call to establish cache
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const firstResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(firstResult.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`);
|
||||
|
||||
// Trigger preference change (but scope remains 'global')
|
||||
expect(preferenceChangeCallback).to.not.be.undefined;
|
||||
preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF });
|
||||
|
||||
// Path should remain the same since global path is fixed
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const secondResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(secondResult.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`);
|
||||
});
|
||||
|
||||
it('should use workspace metadata storage for workspace scope', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// First call to establish cache
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const firstResult = await (chatSessionStore as any).getStorageRoot();
|
||||
expect(firstResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE);
|
||||
|
||||
// Workspace location changes are now handled by WorkspaceMetadataStore.onDidChangeLocation
|
||||
// The store will emit this event and the chat store will invalidate its cache automatically
|
||||
});
|
||||
|
||||
it('should invalidate index cache along with storage root', async () => {
|
||||
mockWorkspaceService.tryGetRoots = () => [
|
||||
{ resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat
|
||||
];
|
||||
|
||||
// Setup index file response
|
||||
const index = { 'session-1': { sessionId: 'session-1', title: 'Test', saveDate: 1000, location: 'panel' } };
|
||||
mockFileService.readFile.resolves({
|
||||
value: BinaryBuffer.fromString(JSON.stringify(index))
|
||||
} as never);
|
||||
|
||||
// Load index (establishes cache)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).loadIndex();
|
||||
|
||||
// Change scope to trigger invalidation
|
||||
mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global');
|
||||
preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF });
|
||||
|
||||
// Load index again - should read from file again
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatSessionStore as any).loadIndex();
|
||||
|
||||
// readFile should have been called twice
|
||||
expect(mockFileService.readFile.callCount).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
553
packages/ai-chat/src/browser/chat-session-store-impl.ts
Normal file
553
packages/ai-chat/src/browser/chat-session-store-impl.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService, WorkspaceMetadataStorageService, WorkspaceMetadataStore } from '@theia/workspace/lib/browser';
|
||||
import { PreferenceService } from '@theia/core/lib/common';
|
||||
import { StorageService } from '@theia/core/lib/browser';
|
||||
import { DisposableCollection, URI } from '@theia/core';
|
||||
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
||||
import { ILogger } from '@theia/core/lib/common/logger';
|
||||
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
|
||||
import { ChatModel } from '../common/chat-model';
|
||||
import { ChatSessionIndex, ChatSessionStore, ChatModelWithMetadata, ChatSessionMetadata } from '../common/chat-session-store';
|
||||
import {
|
||||
PERSISTED_SESSION_LIMIT_PREF,
|
||||
SESSION_STORAGE_PREF,
|
||||
SessionStorageScope
|
||||
} from '../common/ai-chat-preferences';
|
||||
import { SerializedChatData, CHAT_DATA_VERSION } from '../common/chat-model-serialization';
|
||||
|
||||
const INDEX_FILE = 'index.json';
|
||||
|
||||
@injectable()
|
||||
export class ChatSessionStoreImpl implements ChatSessionStore {
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
@inject(WorkspaceMetadataStorageService)
|
||||
protected readonly metadataStorageService: WorkspaceMetadataStorageService;
|
||||
|
||||
@inject(EnvVariablesServer)
|
||||
protected readonly envServer: EnvVariablesServer;
|
||||
|
||||
@inject(StorageService)
|
||||
protected readonly storageService: StorageService;
|
||||
|
||||
@inject(ILogger) @named('ChatSessionStore')
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
protected storageRoot?: URI;
|
||||
protected storageInitialized = false;
|
||||
protected indexCache?: ChatSessionIndex;
|
||||
protected storePromise: Promise<void> = Promise.resolve();
|
||||
protected readonly toDispose = new DisposableCollection();
|
||||
protected workspaceMetadataStore?: WorkspaceMetadataStore;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.toDispose.push(
|
||||
this.preferenceService.onPreferenceChanged(async event => {
|
||||
if (event.preferenceName === SESSION_STORAGE_PREF) {
|
||||
this.logger.debug('Session storage preference changed: invalidating cache.', { preference: event.preferenceName });
|
||||
this.invalidateStorageCache();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected invalidateStorageCache(): void {
|
||||
this.storageRoot = undefined;
|
||||
this.storageInitialized = false;
|
||||
this.indexCache = undefined;
|
||||
// Clear workspace metadata store reference so it will be recreated
|
||||
if (this.workspaceMetadataStore) {
|
||||
this.workspaceMetadataStore = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async storeSessions(...sessions: Array<ChatModel | ChatModelWithMetadata>): Promise<void> {
|
||||
this.storePromise = this.storePromise.then(async () => {
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
this.logger.debug('Session persistence is disabled: skipping store.');
|
||||
return;
|
||||
}
|
||||
this.logger.debug('Starting to store sessions', { totalSessions: sessions.length, storageRoot: root.toString() });
|
||||
|
||||
// Normalize to SessionWithTitle and filter empty sessions
|
||||
const nonEmptySessions = sessions
|
||||
.map(s => this.isChatModelWithMetadata(s) ? { ...s, saveDate: Date.now() } : { model: s, saveDate: Date.now() })
|
||||
.filter(s => !s.model.isEmpty());
|
||||
this.logger.debug('Filtered empty sessions', { nonEmptySessions: nonEmptySessions.length });
|
||||
|
||||
// Write each session as JSON file
|
||||
for (const session of nonEmptySessions) {
|
||||
const sessionFile = root.resolve(`${session.model.id}.json`);
|
||||
const modelData = session.model.toSerializable();
|
||||
// Wrap model data with persistence metadata
|
||||
const data: SerializedChatData = {
|
||||
version: CHAT_DATA_VERSION,
|
||||
title: session.title,
|
||||
pinnedAgentId: session.pinnedAgentId,
|
||||
saveDate: session.saveDate,
|
||||
model: modelData
|
||||
};
|
||||
this.logger.debug('Writing session to file', {
|
||||
sessionId: session.model.id,
|
||||
title: data.title,
|
||||
filePath: sessionFile.toString(),
|
||||
requestCount: modelData.requests.length,
|
||||
responseCount: modelData.responses.length,
|
||||
pinnedAgentId: data.pinnedAgentId,
|
||||
version: data.version
|
||||
});
|
||||
await this.fileService.writeFile(
|
||||
sessionFile,
|
||||
BinaryBuffer.fromString(JSON.stringify(data, undefined, 2))
|
||||
);
|
||||
}
|
||||
|
||||
// Update index with metadata
|
||||
await this.updateIndex(nonEmptySessions);
|
||||
|
||||
// Trim to max sessions
|
||||
await this.trimSessions();
|
||||
this.logger.debug('Finished storing sessions');
|
||||
});
|
||||
return this.storePromise;
|
||||
}
|
||||
|
||||
private isChatModelWithMetadata(session: ChatModel | ChatModelWithMetadata): session is ChatModelWithMetadata {
|
||||
return 'model' in session;
|
||||
}
|
||||
|
||||
async readSession(sessionId: string): Promise<SerializedChatData | undefined> {
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
this.logger.debug('Session persistence is disabled: cannot read session.', { sessionId });
|
||||
return undefined;
|
||||
}
|
||||
const sessionFile = root.resolve(`${sessionId}.json`);
|
||||
this.logger.debug('Reading session from file', { sessionId, filePath: sessionFile.toString() });
|
||||
|
||||
try {
|
||||
const content = await this.fileService.readFile(sessionFile);
|
||||
const parsedData = JSON.parse(content.value.toString());
|
||||
const data = this.migrateData(parsedData);
|
||||
this.logger.debug('Successfully read session', {
|
||||
sessionId,
|
||||
requestCount: data.model.requests.length,
|
||||
responseCount: data.model.responses.length,
|
||||
version: data.version
|
||||
});
|
||||
return data;
|
||||
} catch (e) {
|
||||
this.logger.debug('Failed to read session', { sessionId, error: e });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
this.storePromise = this.storePromise.then(async () => {
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
this.logger.debug('Session persistence is disabled: skipping delete.', { sessionId });
|
||||
return;
|
||||
}
|
||||
const sessionFile = root.resolve(`${sessionId}.json`);
|
||||
this.logger.debug('Deleting session', { sessionId, filePath: sessionFile.toString() });
|
||||
|
||||
try {
|
||||
await this.fileService.delete(sessionFile);
|
||||
this.logger.debug('Session file deleted', { sessionId });
|
||||
} catch (e) {
|
||||
this.logger.debug('Failed to delete session file (may not exist)', { sessionId, error: e });
|
||||
}
|
||||
|
||||
// Update index
|
||||
const index = await this.loadIndex();
|
||||
delete index[sessionId];
|
||||
await this.saveIndex(index);
|
||||
this.logger.debug('Session removed from index', { sessionId });
|
||||
});
|
||||
return this.storePromise;
|
||||
}
|
||||
|
||||
async clearAllSessions(): Promise<void> {
|
||||
this.storePromise = this.storePromise.then(async () => {
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
this.logger.debug('Session persistence is disabled: skipping clear.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fileService.delete(root, { recursive: true });
|
||||
await this.fileService.createFolder(root);
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
this.indexCache = {};
|
||||
await this.saveIndex({});
|
||||
});
|
||||
return this.storePromise;
|
||||
}
|
||||
|
||||
async getSessionIndex(): Promise<ChatSessionIndex> {
|
||||
const index = await this.loadIndex();
|
||||
this.logger.debug('Retrieved session index', { sessionCount: Object.keys(index).length });
|
||||
return index;
|
||||
}
|
||||
|
||||
async setSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
this.storePromise = this.storePromise.then(async () => {
|
||||
const index = await this.loadIndex();
|
||||
if (index[sessionId]) {
|
||||
index[sessionId].title = title;
|
||||
await this.saveIndex(index);
|
||||
}
|
||||
});
|
||||
return this.storePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the storage root URI.
|
||||
* Use {@link ensureStorageReady} when you need actually to access the storage.
|
||||
*/
|
||||
protected async getStorageRoot(): Promise<URI | undefined> {
|
||||
if (this.storageRoot !== undefined) {
|
||||
return this.storageRoot;
|
||||
}
|
||||
|
||||
const resolved = await this.resolveStorageRoot();
|
||||
if (!resolved) {
|
||||
// Persistence is disabled
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.storageRoot = resolved;
|
||||
return this.storageRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the storage directory exists and is initialized on disk.
|
||||
* This should be called before any disk I/O operations.
|
||||
*/
|
||||
protected async ensureStorageReady(): Promise<URI | undefined> {
|
||||
const root = await this.getStorageRoot();
|
||||
if (!root) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.storageInitialized) {
|
||||
await this.initializeStorage(root);
|
||||
this.storageInitialized = true;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the storage directory on disk, creating it if necessary.
|
||||
*/
|
||||
protected async initializeStorage(root: URI): Promise<void> {
|
||||
try {
|
||||
await this.fileService.createFolder(root);
|
||||
} catch (e) {
|
||||
// Folder may already exist
|
||||
}
|
||||
}
|
||||
|
||||
protected async getStorageScope(): Promise<SessionStorageScope> {
|
||||
// Wait for preferences to be ready before reading storage configuration
|
||||
await this.preferenceService.ready;
|
||||
return this.preferenceService.get<SessionStorageScope>(SESSION_STORAGE_PREF, 'workspace');
|
||||
}
|
||||
|
||||
protected async getGlobalStorageRoot(): Promise<URI> {
|
||||
const configDirUri = await this.envServer.getConfigDirUri();
|
||||
return new URI(configDirUri).resolve('chatSessions');
|
||||
}
|
||||
|
||||
protected async resolveStorageRoot(): Promise<URI | undefined> {
|
||||
const scope = await this.getStorageScope();
|
||||
|
||||
if (scope === 'workspace') {
|
||||
if (this.workspaceService.tryGetRoots().length === 0) {
|
||||
this.logger.debug('No workspace open: falling back to global storage.');
|
||||
return this.getGlobalStorageRoot();
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse existing store or get/create one from the service
|
||||
if (!this.workspaceMetadataStore) {
|
||||
this.workspaceMetadataStore = await this.metadataStorageService.getOrCreateStore('chatSessions');
|
||||
// Set up location change listener
|
||||
this.toDispose.push(
|
||||
this.workspaceMetadataStore.onDidChangeLocation(newLocation => {
|
||||
this.logger.debug('Workspace metadata store location changed: invalidating cache.',
|
||||
{ oldRoot: this.storageRoot?.toString(), newRoot: newLocation.toString() });
|
||||
this.invalidateStorageCache();
|
||||
})
|
||||
);
|
||||
this.toDispose.push(this.workspaceMetadataStore);
|
||||
}
|
||||
this.logger.debug('Using workspace metadata storage', { location: this.workspaceMetadataStore.location.toString() });
|
||||
return this.workspaceMetadataStore.location;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create workspace metadata store, falling back to global storage', error);
|
||||
return this.getGlobalStorageRoot();
|
||||
}
|
||||
}
|
||||
|
||||
// Global storage mode
|
||||
const globalPath = await this.getGlobalStorageRoot();
|
||||
this.logger.debug('Using global storage path', { path: globalPath.toString() });
|
||||
return globalPath;
|
||||
}
|
||||
|
||||
protected async updateIndex(sessions: ((ChatModelWithMetadata & { saveDate: number })[])): Promise<void> {
|
||||
const index = await this.loadIndex();
|
||||
|
||||
for (const session of sessions) {
|
||||
const data = session.model.toSerializable();
|
||||
const { model, ...metadata } = session;
|
||||
const previousData = index[model.id];
|
||||
index[model.id] = {
|
||||
...previousData,
|
||||
sessionId: model.id,
|
||||
location: data.location,
|
||||
...metadata
|
||||
};
|
||||
}
|
||||
|
||||
await this.saveIndex(index);
|
||||
}
|
||||
|
||||
protected getPersistedSessionLimit(): number {
|
||||
return this.preferenceService.get<number>(PERSISTED_SESSION_LIMIT_PREF, 25);
|
||||
}
|
||||
|
||||
protected async trimSessions(): Promise<void> {
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxSessions = this.getPersistedSessionLimit();
|
||||
|
||||
// -1 means unlimited, skip trimming
|
||||
if (maxSessions === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = await this.loadIndex();
|
||||
const sessions = Object.values(index);
|
||||
|
||||
// 0 means no persistence - delete all sessions
|
||||
if (maxSessions === 0) {
|
||||
this.logger.debug('Session persistence disabled, deleting all sessions', { sessionCount: sessions.length });
|
||||
for (const session of sessions) {
|
||||
const sessionFile = root.resolve(`${session.sessionId}.json`);
|
||||
try {
|
||||
await this.fileService.delete(sessionFile);
|
||||
} catch (e) {
|
||||
this.logger.debug('Failed to delete session file', { sessionId: session.sessionId, error: e });
|
||||
}
|
||||
delete index[session.sessionId];
|
||||
}
|
||||
await this.saveIndex(index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessions.length <= maxSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug('Trimming sessions', { currentCount: sessions.length, maxSessions });
|
||||
|
||||
// Sort by save date (oldest first)
|
||||
sessions.sort((a, b) => a.saveDate - b.saveDate);
|
||||
|
||||
// Delete oldest sessions beyond the limit
|
||||
const sessionsToDelete = sessions.slice(0, sessions.length - maxSessions);
|
||||
this.logger.debug('Deleting oldest sessions', { deleteCount: sessionsToDelete.length, sessionIds: sessionsToDelete.map(s => s.sessionId) });
|
||||
|
||||
for (const session of sessionsToDelete) {
|
||||
const sessionFile = root.resolve(`${session.sessionId}.json`);
|
||||
try {
|
||||
await this.fileService.delete(sessionFile);
|
||||
} catch (e) {
|
||||
this.logger.debug('Failed to delete session file', { sessionId: session.sessionId, error: e });
|
||||
}
|
||||
delete index[session.sessionId];
|
||||
}
|
||||
|
||||
await this.saveIndex(index);
|
||||
}
|
||||
|
||||
protected async loadIndex(): Promise<ChatSessionIndex> {
|
||||
if (this.indexCache) {
|
||||
return this.indexCache;
|
||||
}
|
||||
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
this.indexCache = {};
|
||||
return this.indexCache;
|
||||
}
|
||||
const indexFile = root.resolve(INDEX_FILE);
|
||||
|
||||
try {
|
||||
const content = await this.fileService.readFile(indexFile);
|
||||
const rawIndex = JSON.parse(content.value.toString());
|
||||
|
||||
// Validate and clean up index entries
|
||||
const validatedIndex: ChatSessionIndex = {};
|
||||
let hasInvalidEntries = false;
|
||||
|
||||
for (const [sessionId, metadata] of Object.entries(rawIndex)) {
|
||||
// Check if entry has required fields and valid values
|
||||
if (this.isValidMetadata(metadata)) {
|
||||
validatedIndex[sessionId] = metadata as ChatSessionMetadata;
|
||||
} else {
|
||||
hasInvalidEntries = true;
|
||||
this.logger.warn('Removing invalid session metadata from index', {
|
||||
sessionId,
|
||||
metadata
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we removed any entries, persist the cleaned index
|
||||
if (hasInvalidEntries) {
|
||||
this.logger.info('Index cleaned up, removing invalid entries');
|
||||
await this.fileService.writeFile(
|
||||
indexFile,
|
||||
BinaryBuffer.fromString(JSON.stringify(validatedIndex, undefined, 2))
|
||||
);
|
||||
}
|
||||
|
||||
this.indexCache = validatedIndex;
|
||||
return this.indexCache;
|
||||
} catch (e) {
|
||||
this.indexCache = {};
|
||||
return this.indexCache;
|
||||
}
|
||||
}
|
||||
|
||||
protected isValidMetadata(metadata: unknown): metadata is ChatSessionMetadata {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const m = metadata as Record<string, unknown>;
|
||||
|
||||
// Check required fields exist and have correct types
|
||||
return typeof m.sessionId === 'string' &&
|
||||
typeof m.title === 'string' &&
|
||||
typeof m.saveDate === 'number' &&
|
||||
typeof m.location === 'string' &&
|
||||
// Ensure saveDate is a valid timestamp
|
||||
!isNaN(m.saveDate) &&
|
||||
m.saveDate > 0;
|
||||
}
|
||||
|
||||
protected async saveIndex(index: ChatSessionIndex): Promise<void> {
|
||||
this.indexCache = index;
|
||||
const root = await this.ensureStorageReady();
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
const indexFile = root.resolve(INDEX_FILE);
|
||||
|
||||
await this.fileService.writeFile(
|
||||
indexFile,
|
||||
BinaryBuffer.fromString(JSON.stringify(index, undefined, 2))
|
||||
);
|
||||
}
|
||||
|
||||
protected migrateData(data: unknown): SerializedChatData {
|
||||
const parsed = data as SerializedChatData;
|
||||
|
||||
// Defensive check for unexpected future versions
|
||||
if (parsed.version && parsed.version > CHAT_DATA_VERSION) {
|
||||
this.logger.warn(
|
||||
`Session data version ${parsed.version} is newer than supported ${CHAT_DATA_VERSION}. ` +
|
||||
'Data may not load correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async hasPersistedSessions(): Promise<boolean> {
|
||||
// If we already have cached sessions, return true immediately
|
||||
if (this.indexCache && Object.keys(this.indexCache).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storageRoot = await this.getStorageRoot();
|
||||
if (!storageRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const indexFile = storageRoot.resolve(INDEX_FILE);
|
||||
try {
|
||||
const exists = await this.fileService.exists(indexFile);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = await this.fileService.readFile(indexFile);
|
||||
const index = JSON.parse(content.value.toString());
|
||||
return Object.keys(index).length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected async hasGlobalSessions(): Promise<boolean> {
|
||||
const globalRoot = await this.getGlobalStorageRoot();
|
||||
|
||||
try {
|
||||
const indexFile = globalRoot.resolve(INDEX_FILE);
|
||||
const exists = await this.fileService.exists(indexFile);
|
||||
if (!exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = await this.fileService.readFile(indexFile);
|
||||
const index = JSON.parse(content.value.toString());
|
||||
return Object.keys(index).length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { ToolConfirmationManager } from './chat-tool-preference-bindings';
|
||||
import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE, ChatToolPreferences } from '../common/chat-tool-preferences';
|
||||
import { ToolRequest } from '@theia/ai-core';
|
||||
import { PreferenceService } from '@theia/core/lib/common/preferences';
|
||||
|
||||
describe('ToolConfirmationManager', () => {
|
||||
let manager: ToolConfirmationManager;
|
||||
let preferenceServiceMock: sinon.SinonStubbedInstance<PreferenceService>;
|
||||
let storedPreferences: { [toolId: string]: ToolConfirmationMode };
|
||||
|
||||
const createToolRequest = (id: string, confirmAlwaysAllow?: boolean | string): ToolRequest => ({
|
||||
id,
|
||||
name: id,
|
||||
handler: async () => '',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
confirmAlwaysAllow
|
||||
});
|
||||
|
||||
const getPreferencesMock = (): ChatToolPreferences => ({
|
||||
get [TOOL_CONFIRMATION_PREFERENCE](): { [toolId: string]: ToolConfirmationMode } {
|
||||
return storedPreferences;
|
||||
}
|
||||
}) as unknown as ChatToolPreferences;
|
||||
|
||||
beforeEach(() => {
|
||||
storedPreferences = {};
|
||||
|
||||
preferenceServiceMock = {
|
||||
updateValue: sinon.stub().callsFake((_key: string, value: { [toolId: string]: ToolConfirmationMode }) => {
|
||||
storedPreferences = value;
|
||||
return Promise.resolve();
|
||||
})
|
||||
} as unknown as sinon.SinonStubbedInstance<PreferenceService>;
|
||||
|
||||
manager = new ToolConfirmationManager();
|
||||
(manager as unknown as { preferences: ChatToolPreferences }).preferences = getPreferencesMock();
|
||||
(manager as unknown as { preferenceService: PreferenceService }).preferenceService = preferenceServiceMock;
|
||||
});
|
||||
|
||||
describe('getConfirmationMode', () => {
|
||||
it('should return ALWAYS_ALLOW for regular tools by default', () => {
|
||||
const mode = manager.getConfirmationMode('regularTool', 'chat-1');
|
||||
expect(mode).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
|
||||
it('should return CONFIRM for confirmAlwaysAllow tools by default', () => {
|
||||
const toolRequest = createToolRequest('dangerousTool', true);
|
||||
const mode = manager.getConfirmationMode('dangerousTool', 'chat-1', toolRequest);
|
||||
expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
|
||||
});
|
||||
|
||||
it('should return tool-specific preference when set', () => {
|
||||
storedPreferences['myTool'] = ToolConfirmationMode.DISABLED;
|
||||
const mode = manager.getConfirmationMode('myTool', 'chat-1');
|
||||
expect(mode).to.equal(ToolConfirmationMode.DISABLED);
|
||||
});
|
||||
|
||||
it('should return session override when set', () => {
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.ALWAYS_ALLOW, 'chat-1');
|
||||
const mode = manager.getConfirmationMode('myTool', 'chat-1');
|
||||
expect(mode).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
|
||||
it('should not inherit global ALWAYS_ALLOW for confirmAlwaysAllow tools', () => {
|
||||
storedPreferences['*'] = ToolConfirmationMode.ALWAYS_ALLOW;
|
||||
const toolRequest = createToolRequest('dangerousTool', true);
|
||||
const mode = manager.getConfirmationMode('dangerousTool', 'chat-1', toolRequest);
|
||||
expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
|
||||
});
|
||||
|
||||
it('should inherit global DISABLED for confirmAlwaysAllow tools', () => {
|
||||
storedPreferences['*'] = ToolConfirmationMode.DISABLED;
|
||||
const toolRequest = createToolRequest('dangerousTool', true);
|
||||
const mode = manager.getConfirmationMode('dangerousTool', 'chat-1', toolRequest);
|
||||
expect(mode).to.equal(ToolConfirmationMode.DISABLED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConfirmationMode', () => {
|
||||
it('should persist ALWAYS_ALLOW for regular tools when different from default', () => {
|
||||
storedPreferences['*'] = ToolConfirmationMode.CONFIRM;
|
||||
manager.setConfirmationMode('regularTool', ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['regularTool']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
|
||||
it('should persist ALWAYS_ALLOW for confirmAlwaysAllow tools', () => {
|
||||
const toolRequest = createToolRequest('dangerousTool', true);
|
||||
manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest);
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['dangerousTool']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
|
||||
it('should not persist ALWAYS_ALLOW for regular tools when it matches default', () => {
|
||||
manager.setConfirmationMode('regularTool', ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
expect(preferenceServiceMock.updateValue.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should remove entry when setting mode that matches effective default', () => {
|
||||
storedPreferences['regularTool'] = ToolConfirmationMode.CONFIRM;
|
||||
manager.setConfirmationMode('regularTool', ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['regularTool']).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should persist DISABLED for any tool', () => {
|
||||
manager.setConfirmationMode('anyTool', ToolConfirmationMode.DISABLED);
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['anyTool']).to.equal(ToolConfirmationMode.DISABLED);
|
||||
});
|
||||
|
||||
it('should remove entry when setting CONFIRM for confirmAlwaysAllow tools (matches effective default)', () => {
|
||||
const toolRequest = createToolRequest('dangerousTool', true);
|
||||
storedPreferences['dangerousTool'] = ToolConfirmationMode.ALWAYS_ALLOW;
|
||||
manager.setConfirmationMode('dangerousTool', ToolConfirmationMode.CONFIRM, toolRequest);
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['dangerousTool']).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionConfirmationMode', () => {
|
||||
it('should set session override for specific chat', () => {
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.ALWAYS_ALLOW, 'chat-1');
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-1')).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-2')).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
|
||||
it('should prioritize session override over persisted preference', () => {
|
||||
storedPreferences['myTool'] = ToolConfirmationMode.DISABLED;
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.ALWAYS_ALLOW, 'chat-1');
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-1')).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSessionOverrides', () => {
|
||||
it('should clear overrides for specific chat', () => {
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.ALWAYS_ALLOW, 'chat-1');
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.DISABLED, 'chat-2');
|
||||
|
||||
manager.clearSessionOverrides('chat-1');
|
||||
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-1')).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-2')).to.equal(ToolConfirmationMode.DISABLED);
|
||||
});
|
||||
|
||||
it('should clear all overrides when no chatId provided', () => {
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.ALWAYS_ALLOW, 'chat-1');
|
||||
manager.setSessionConfirmationMode('myTool', ToolConfirmationMode.DISABLED, 'chat-2');
|
||||
|
||||
manager.clearSessionOverrides();
|
||||
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-1')).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
expect(manager.getConfirmationMode('myTool', 'chat-2')).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmAlwaysAllow tools - "Always Approve" workflow', () => {
|
||||
it('should persist "Always Allow" for confirmAlwaysAllow tools', () => {
|
||||
const toolRequest = createToolRequest('shellExecute', 'This tool has full system access.');
|
||||
|
||||
let mode = manager.getConfirmationMode('shellExecute', 'chat-1', toolRequest);
|
||||
expect(mode).to.equal(ToolConfirmationMode.CONFIRM);
|
||||
|
||||
manager.setConfirmationMode('shellExecute', ToolConfirmationMode.ALWAYS_ALLOW, toolRequest);
|
||||
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['shellExecute']).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
|
||||
mode = manager.getConfirmationMode('shellExecute', 'chat-1', toolRequest);
|
||||
expect(mode).to.equal(ToolConfirmationMode.ALWAYS_ALLOW);
|
||||
});
|
||||
|
||||
it('should persist "Disabled" for confirmAlwaysAllow tools', () => {
|
||||
const toolRequest = createToolRequest('shellExecute', true);
|
||||
|
||||
manager.setConfirmationMode('shellExecute', ToolConfirmationMode.DISABLED, toolRequest);
|
||||
|
||||
expect(preferenceServiceMock.updateValue.calledOnce).to.be.true;
|
||||
expect(storedPreferences['shellExecute']).to.equal(ToolConfirmationMode.DISABLED);
|
||||
|
||||
const mode = manager.getConfirmationMode('shellExecute', 'chat-1', toolRequest);
|
||||
expect(mode).to.equal(ToolConfirmationMode.DISABLED);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
packages/ai-chat/src/browser/chat-tool-preference-bindings.ts
Normal file
138
packages/ai-chat/src/browser/chat-tool-preference-bindings.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
PreferenceService,
|
||||
} from '@theia/core/lib/common/preferences';
|
||||
import { ToolConfirmationMode, TOOL_CONFIRMATION_PREFERENCE, ChatToolPreferences } from '../common/chat-tool-preferences';
|
||||
import { ToolRequest } from '@theia/ai-core';
|
||||
|
||||
/**
|
||||
* Utility class to manage tool confirmation settings
|
||||
*/
|
||||
@injectable()
|
||||
export class ToolConfirmationManager {
|
||||
@inject(ChatToolPreferences)
|
||||
protected readonly preferences: ChatToolPreferences;
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
// In-memory session overrides (not persisted), per chat
|
||||
protected sessionOverrides: Map<string, Map<string, ToolConfirmationMode>> = new Map();
|
||||
|
||||
/**
|
||||
* Get the confirmation mode for a specific tool, considering session overrides first (per chat).
|
||||
*
|
||||
* For tools with `confirmAlwaysAllow` flag:
|
||||
* - They default to CONFIRM mode instead of ALWAYS_ALLOW
|
||||
* - They don't inherit global ALWAYS_ALLOW from the '*' preference
|
||||
*
|
||||
* @param toolId - The tool identifier
|
||||
* @param chatId - The chat session identifier
|
||||
* @param toolRequest - Optional ToolRequest to check for confirmAlwaysAllow flag
|
||||
*/
|
||||
getConfirmationMode(toolId: string, chatId: string, toolRequest?: ToolRequest): ToolConfirmationMode {
|
||||
const chatMap = this.sessionOverrides.get(chatId);
|
||||
if (chatMap && chatMap.has(toolId)) {
|
||||
return chatMap.get(toolId)!;
|
||||
}
|
||||
const toolConfirmation = this.preferences[TOOL_CONFIRMATION_PREFERENCE];
|
||||
if (toolConfirmation[toolId]) {
|
||||
return toolConfirmation[toolId];
|
||||
}
|
||||
if (toolConfirmation['*']) {
|
||||
// For confirmAlwaysAllow tools, don't inherit global ALWAYS_ALLOW
|
||||
if (toolRequest?.confirmAlwaysAllow && toolConfirmation['*'] === ToolConfirmationMode.ALWAYS_ALLOW) {
|
||||
return ToolConfirmationMode.CONFIRM;
|
||||
}
|
||||
return toolConfirmation['*'];
|
||||
}
|
||||
|
||||
// Default: ALWAYS_ALLOW for normal tools, CONFIRM for confirmAlwaysAllow tools
|
||||
return toolRequest?.confirmAlwaysAllow
|
||||
? ToolConfirmationMode.CONFIRM
|
||||
: ToolConfirmationMode.ALWAYS_ALLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the confirmation mode for a specific tool (persisted)
|
||||
*
|
||||
* @param toolId - The tool identifier
|
||||
* @param mode - The confirmation mode to set
|
||||
* @param toolRequest - Optional ToolRequest to check for confirmAlwaysAllow flag
|
||||
*/
|
||||
setConfirmationMode(toolId: string, mode: ToolConfirmationMode, toolRequest?: ToolRequest): void {
|
||||
const current = this.preferences[TOOL_CONFIRMATION_PREFERENCE] || {};
|
||||
let starMode = current['*'];
|
||||
if (starMode === undefined) {
|
||||
starMode = ToolConfirmationMode.ALWAYS_ALLOW;
|
||||
}
|
||||
// For confirmAlwaysAllow tools, the effective default is CONFIRM, not ALWAYS_ALLOW
|
||||
const effectiveDefault = (toolRequest?.confirmAlwaysAllow && starMode === ToolConfirmationMode.ALWAYS_ALLOW)
|
||||
? ToolConfirmationMode.CONFIRM
|
||||
: starMode;
|
||||
if (mode === effectiveDefault) {
|
||||
if (toolId in current) {
|
||||
const { [toolId]: _, ...rest } = current;
|
||||
this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, rest);
|
||||
}
|
||||
} else {
|
||||
const updated = { ...current, [toolId]: mode };
|
||||
this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, updated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the confirmation mode for a specific tool for this session only (not persisted, per chat)
|
||||
*/
|
||||
setSessionConfirmationMode(toolId: string, mode: ToolConfirmationMode, chatId: string): void {
|
||||
let chatMap = this.sessionOverrides.get(chatId);
|
||||
if (!chatMap) {
|
||||
chatMap = new Map();
|
||||
this.sessionOverrides.set(chatId, chatMap);
|
||||
}
|
||||
chatMap.set(toolId, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all session overrides for a specific chat, or all if no chatId is given
|
||||
*/
|
||||
clearSessionOverrides(chatId?: string): void {
|
||||
if (chatId) {
|
||||
this.sessionOverrides.delete(chatId);
|
||||
} else {
|
||||
this.sessionOverrides.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool confirmation settings
|
||||
*/
|
||||
getAllConfirmationSettings(): { [toolId: string]: ToolConfirmationMode } {
|
||||
return this.preferences[TOOL_CONFIRMATION_PREFERENCE] || {};
|
||||
}
|
||||
|
||||
resetAllConfirmationModeSettings(): void {
|
||||
const current = this.preferences[TOOL_CONFIRMATION_PREFERENCE] || {};
|
||||
if ('*' in current) {
|
||||
this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, { '*': current['*'] });
|
||||
} else {
|
||||
this.preferenceService.updateValue(TOOL_CONFIRMATION_PREFERENCE, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
133
packages/ai-chat/src/browser/chat-tool-request-service.ts
Normal file
133
packages/ai-chat/src/browser/chat-tool-request-service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ToolInvocationContext, ToolRequest } from '@theia/ai-core';
|
||||
import { ILogger } from '@theia/core';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import { ChatToolRequestService, normalizeToolArgs } from '../common/chat-tool-request-service';
|
||||
import { MutableChatRequestModel, ToolCallChatResponseContent } from '../common/chat-model';
|
||||
import { ToolConfirmationMode, ChatToolPreferences } from '../common/chat-tool-preferences';
|
||||
import { ToolConfirmationManager } from './chat-tool-preference-bindings';
|
||||
|
||||
/**
|
||||
* Frontend-specific implementation of ChatToolRequestService that handles tool confirmation
|
||||
*/
|
||||
@injectable()
|
||||
export class FrontendChatToolRequestService extends ChatToolRequestService {
|
||||
|
||||
@inject(ILogger) @named('ChatToolRequestService')
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
@inject(ToolConfirmationManager)
|
||||
protected readonly confirmationManager: ToolConfirmationManager;
|
||||
|
||||
@inject(ChatToolPreferences)
|
||||
protected readonly preferences: ChatToolPreferences;
|
||||
|
||||
protected override toChatToolRequest(toolRequest: ToolRequest, request: MutableChatRequestModel): ToolRequest {
|
||||
const confirmationMode = this.confirmationManager.getConfirmationMode(toolRequest.id, request.session.id, toolRequest);
|
||||
|
||||
return {
|
||||
...toolRequest,
|
||||
handler: async (arg_string: string, ctx?: ToolInvocationContext) => {
|
||||
const toolCallId = ctx?.toolCallId;
|
||||
|
||||
switch (confirmationMode) {
|
||||
case ToolConfirmationMode.DISABLED:
|
||||
return { denied: true, message: `Tool ${toolRequest.id} is disabled` };
|
||||
|
||||
case ToolConfirmationMode.ALWAYS_ALLOW: {
|
||||
const toolCallContentAlwaysAllow = this.findToolCallContent(toolRequest, arg_string, request, toolCallId);
|
||||
toolCallContentAlwaysAllow.confirm();
|
||||
const result = await toolRequest.handler(arg_string, this.createToolContext(request, ToolInvocationContext.create(toolCallContentAlwaysAllow.id)));
|
||||
// Signal completion for immediate UI update. The language model uses Promise.all
|
||||
// for parallel tools, so without this the UI wouldn't update until all tools finish.
|
||||
// The result will be overwritten with the same value when the LLM stream yields it.
|
||||
toolCallContentAlwaysAllow.complete(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case ToolConfirmationMode.CONFIRM:
|
||||
default: {
|
||||
const toolCallContent = this.findToolCallContent(toolRequest, arg_string, request, toolCallId);
|
||||
const confirmed = await toolCallContent.confirmed;
|
||||
|
||||
if (confirmed) {
|
||||
const result = await toolRequest.handler(arg_string, this.createToolContext(request, ToolInvocationContext.create(toolCallContent.id)));
|
||||
// Signal completion for immediate UI update (see ALWAYS_ALLOW case for details)
|
||||
toolCallContent.complete(result);
|
||||
return result;
|
||||
} else {
|
||||
return toolCallContent.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matching ToolCallChatResponseContent for a tool invocation.
|
||||
*
|
||||
* Matches by: 1) toolCallId, 2) tool name + normalized arguments, 3) fallback by name only.
|
||||
*/
|
||||
protected findToolCallContent(
|
||||
toolRequest: ToolRequest,
|
||||
arguments_: string,
|
||||
request: MutableChatRequestModel,
|
||||
toolCallId?: string
|
||||
): ToolCallChatResponseContent {
|
||||
const response = request.response.response;
|
||||
const contentArray = response.content;
|
||||
|
||||
// Match on toolCallId first if LLM made it available
|
||||
if (toolCallId !== undefined) {
|
||||
for (let i = contentArray.length - 1; i >= 0; i--) {
|
||||
const content = contentArray[i];
|
||||
if (ToolCallChatResponseContent.is(content) && content.id === toolCallId) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize arguments for comparison to handle differences in empty argument representation
|
||||
const normalizedArguments = normalizeToolArgs(arguments_);
|
||||
|
||||
// Fall back to matching on tool name and normalized arguments
|
||||
for (let i = contentArray.length - 1; i >= 0; i--) {
|
||||
const content = contentArray[i];
|
||||
if (ToolCallChatResponseContent.is(content) &&
|
||||
content.name === toolRequest.id &&
|
||||
normalizeToolArgs(content.arguments) === normalizedArguments) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: match on tool name only
|
||||
for (let i = contentArray.length - 1; i >= 0; i--) {
|
||||
const content = contentArray[i];
|
||||
if (ToolCallChatResponseContent.is(content) &&
|
||||
content.name === toolRequest.id &&
|
||||
!content.finished) {
|
||||
this.logger.warn(`Tool call content for tool ${toolRequest.id} matched by incomplete status fallback. ` +
|
||||
`Expected toolCallId: ${toolCallId}, arguments: ${arguments_}`);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Tool call content for tool ${toolRequest.id} not found in the response`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { URI } from '@theia/core';
|
||||
|
||||
export enum FileValidationState {
|
||||
VALID = 'valid',
|
||||
INVALID_SECONDARY = 'invalid-secondary',
|
||||
INVALID_NOT_FOUND = 'invalid-not-found'
|
||||
}
|
||||
|
||||
export interface FileValidationResult {
|
||||
state: FileValidationState;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ContextFileValidationService {
|
||||
validateFile(pathOrUri: string | URI): Promise<FileValidationResult>;
|
||||
}
|
||||
|
||||
export const ContextFileValidationService = Symbol('ContextFileValidationService');
|
||||
@@ -0,0 +1,62 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIVariableResolutionRequest } from '@theia/ai-core';
|
||||
import { URI } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { LabelProvider, LabelProviderContribution } from '@theia/core/lib/browser';
|
||||
import { ChangeSetFileService } from './change-set-file-service';
|
||||
|
||||
@injectable()
|
||||
export class ContextFileVariableLabelProvider implements LabelProviderContribution {
|
||||
|
||||
@inject(LabelProvider)
|
||||
protected readonly labelProvider: LabelProvider;
|
||||
|
||||
@inject(ChangeSetFileService)
|
||||
protected readonly changeSetFileService: ChangeSetFileService;
|
||||
|
||||
canHandle(element: object): number {
|
||||
if (AIVariableResolutionRequest.is(element) && element.variable.name === 'file') {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
getIcon(element: object): string | undefined {
|
||||
return this.labelProvider.getIcon(this.getUri(element)!);
|
||||
}
|
||||
|
||||
getName(element: object): string | undefined {
|
||||
return this.labelProvider.getName(this.getUri(element)!);
|
||||
}
|
||||
|
||||
getLongName(element: object): string | undefined {
|
||||
return this.labelProvider.getLongName(this.getUri(element)!);
|
||||
}
|
||||
|
||||
getDetails(element: object): string | undefined {
|
||||
return this.labelProvider.getDetails(this.getUri(element)!);
|
||||
}
|
||||
|
||||
protected getUri(element: object): URI | undefined {
|
||||
if (!AIVariableResolutionRequest.is(element)) {
|
||||
return undefined;
|
||||
}
|
||||
return new URI(element.arg);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIVariableResolutionRequest } from '@theia/ai-core';
|
||||
import { LabelProviderContribution } from '@theia/core/lib/browser';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
|
||||
@injectable()
|
||||
export class ContextVariableLabelProvider implements LabelProviderContribution {
|
||||
|
||||
canHandle(element: object): number {
|
||||
if (AIVariableResolutionRequest.is(element)) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
getIcon(element: object): string | undefined {
|
||||
return 'codicon codicon-variable';
|
||||
}
|
||||
|
||||
getName(element: object): string | undefined {
|
||||
if (!AIVariableResolutionRequest.is(element)) {
|
||||
return undefined;
|
||||
}
|
||||
return element.variable.name;
|
||||
}
|
||||
|
||||
getLongName(element: object): string | undefined {
|
||||
if (!AIVariableResolutionRequest.is(element)) {
|
||||
return undefined;
|
||||
}
|
||||
return element.variable.name + (element.arg ? ':' + element.arg : '');
|
||||
}
|
||||
|
||||
getDetails(element: object): string | undefined {
|
||||
if (!AIVariableResolutionRequest.is(element)) {
|
||||
return undefined;
|
||||
}
|
||||
return element.arg;
|
||||
}
|
||||
|
||||
}
|
||||
20
packages/ai-chat/src/browser/custom-agent-factory.ts
Normal file
20
packages/ai-chat/src/browser/custom-agent-factory.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { CustomChatAgent } from '../common';
|
||||
|
||||
export const CustomAgentFactory = Symbol('CustomAgentFactory');
|
||||
export type CustomAgentFactory = (id: string, name: string, description: string, prompt: string, defaultLLM: string) => CustomChatAgent;
|
||||
@@ -0,0 +1,73 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AgentService, CustomAgentDescription, PromptFragmentCustomizationService } from '@theia/ai-core';
|
||||
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { inject, injectable, optional } from '@theia/core/shared/inversify';
|
||||
import { ChatAgentService } from '../common';
|
||||
import { CustomAgentFactory } from './custom-agent-factory';
|
||||
|
||||
@injectable()
|
||||
export class AICustomAgentsFrontendApplicationContribution implements FrontendApplicationContribution {
|
||||
@inject(CustomAgentFactory)
|
||||
protected readonly customAgentFactory: CustomAgentFactory;
|
||||
|
||||
@inject(PromptFragmentCustomizationService) @optional()
|
||||
protected readonly customizationService: PromptFragmentCustomizationService;
|
||||
|
||||
@inject(AgentService)
|
||||
private readonly agentService: AgentService;
|
||||
|
||||
@inject(ChatAgentService)
|
||||
private readonly chatAgentService: ChatAgentService;
|
||||
|
||||
private knownCustomAgents: Map<string, CustomAgentDescription> = new Map();
|
||||
onStart(): void {
|
||||
this.customizationService?.getCustomAgents().then(customAgents => {
|
||||
customAgents.forEach(agent => {
|
||||
this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM);
|
||||
this.knownCustomAgents.set(agent.id, agent);
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error('Failed to load custom agents', e);
|
||||
});
|
||||
this.customizationService?.onDidChangeCustomAgents(() => {
|
||||
this.customizationService?.getCustomAgents().then(customAgents => {
|
||||
const customAgentsToAdd = customAgents.filter(agent =>
|
||||
!this.knownCustomAgents.has(agent.id) || !CustomAgentDescription.equals(this.knownCustomAgents.get(agent.id)!, agent));
|
||||
const customAgentIdsToRemove = [...this.knownCustomAgents.values()].filter(agent =>
|
||||
!customAgents.find(a => CustomAgentDescription.equals(a, agent))).map(a => a.id);
|
||||
|
||||
// delete first so we don't have to deal with the case where we add and remove the same agentId
|
||||
customAgentIdsToRemove.forEach(id => {
|
||||
this.chatAgentService.unregisterChatAgent(id);
|
||||
this.agentService.unregisterAgent(id);
|
||||
this.knownCustomAgents.delete(id);
|
||||
});
|
||||
customAgentsToAdd
|
||||
.forEach(agent => {
|
||||
this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM);
|
||||
this.knownCustomAgents.set(agent.id, agent);
|
||||
});
|
||||
}).catch(e => {
|
||||
console.error('Failed to load custom agents', e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onStop(): void {
|
||||
}
|
||||
}
|
||||
50
packages/ai-chat/src/browser/delegation-response-content.ts
Normal file
50
packages/ai-chat/src/browser/delegation-response-content.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { isObject } from '@theia/core';
|
||||
import { ChatRequestInvocation, ChatResponseContent } from '../common';
|
||||
|
||||
/**
|
||||
* Response Content created when an Agent delegates a prompt to another agent.
|
||||
* Contains agent id, delegated prompt, and the response.
|
||||
*/
|
||||
export class DelegationResponseContent implements ChatResponseContent {
|
||||
kind = 'AgentDelegation';
|
||||
|
||||
/**
|
||||
* @param agentId The id of the agent to whom the task was delegated
|
||||
* @param prompt The prompt that was delegated
|
||||
* @param response The response from the delegated agent
|
||||
*/
|
||||
constructor(
|
||||
public agentId: string,
|
||||
public prompt: string,
|
||||
public response: ChatRequestInvocation
|
||||
) { }
|
||||
|
||||
asString(): string | undefined {
|
||||
// The delegation and response is already part of a tool call and therefore does not need to be repeated
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDelegationResponseContent(
|
||||
value: unknown
|
||||
): value is DelegationResponseContent {
|
||||
return (
|
||||
isObject<DelegationResponseContent>(value) &&
|
||||
value.kind === 'AgentDelegation'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
||||
let disableJSDOM = enableJSDOM();
|
||||
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
|
||||
FrontendApplicationConfigProvider.set({});
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { FileChatVariableContribution } from './file-chat-variable-contribution';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { ILogger, URI } from '@theia/core';
|
||||
import { ApplicationShell } from '@theia/core/lib/browser';
|
||||
|
||||
class TestContribution extends FileChatVariableContribution {
|
||||
public override async fileToBase64(uri: URI): Promise<string> {
|
||||
return super.fileToBase64(uri);
|
||||
}
|
||||
|
||||
public override getMimeTypeFromExtension(filePath: string): string {
|
||||
return super.getMimeTypeFromExtension(filePath);
|
||||
}
|
||||
|
||||
public override isImageFile(filePath: string): boolean {
|
||||
return super.isImageFile(filePath);
|
||||
}
|
||||
|
||||
public override registerVariables(): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
disableJSDOM();
|
||||
|
||||
describe('FileChatVariableContribution', () => {
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
|
||||
let fileService: sinon.SinonStubbedInstance<FileService>;
|
||||
let wsService: sinon.SinonStubbedInstance<WorkspaceService>;
|
||||
let logger: sinon.SinonStubbedInstance<ILogger>;
|
||||
|
||||
let contribution: TestContribution;
|
||||
|
||||
before(() => {
|
||||
disableJSDOM = enableJSDOM();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
disableJSDOM();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox();
|
||||
|
||||
fileService = {
|
||||
readFile: sandbox.stub(),
|
||||
exists: sandbox.stub(),
|
||||
} as unknown as sinon.SinonStubbedInstance<FileService>;
|
||||
|
||||
wsService = {
|
||||
getWorkspaceRelativePath: sandbox.stub(),
|
||||
} as unknown as sinon.SinonStubbedInstance<WorkspaceService>;
|
||||
|
||||
logger = {
|
||||
error: sandbox.stub(),
|
||||
} as unknown as sinon.SinonStubbedInstance<ILogger>;
|
||||
|
||||
contribution = new TestContribution();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(contribution as any).fileService = fileService;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(contribution as any).wsService = wsService;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(contribution as any).logger = logger;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('should return empty base64 string and log error when reading fails', async () => {
|
||||
const uri = new URI('file:///test.png');
|
||||
fileService.readFile.rejects(new Error('read failed'));
|
||||
|
||||
const result = await contribution.fileToBase64(uri);
|
||||
|
||||
expect(result).to.equal('');
|
||||
expect(logger.error.called).to.be.true;
|
||||
});
|
||||
|
||||
it('should not create image request on drop when base64 conversion fails', async () => {
|
||||
const imageUri = new URI('file:///test.png');
|
||||
|
||||
sandbox.stub(ApplicationShell, 'getDraggedEditorUris').returns([imageUri]);
|
||||
fileService.exists.resolves(true);
|
||||
wsService.getWorkspaceRelativePath.resolves('test.png');
|
||||
|
||||
sandbox.stub(contribution, 'isImageFile').returns(true);
|
||||
sandbox.stub(contribution, 'fileToBase64').resolves('');
|
||||
|
||||
const event = {
|
||||
dataTransfer: {},
|
||||
} as unknown as DragEvent;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (contribution as any).handleDrop(event, {} as any);
|
||||
|
||||
expect(result).to.deep.equal({ variables: [], text: undefined });
|
||||
});
|
||||
});
|
||||
266
packages/ai-chat/src/browser/file-chat-variable-contribution.ts
Normal file
266
packages/ai-chat/src/browser/file-chat-variable-contribution.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIVariableContext, AIVariableResolutionRequest, PromptText } from '@theia/ai-core';
|
||||
import { AIVariableCompletionContext, AIVariableDropResult, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser';
|
||||
import { FILE_VARIABLE } from '@theia/ai-core/lib/browser/file-variable-contribution';
|
||||
import { CancellationToken, ILogger, nls, QuickInputService, URI } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
import { FileQuickPickItem, QuickFileSelectService } from '@theia/file-search/lib/browser/quick-file-select-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { VARIABLE_ADD_CONTEXT_COMMAND } from './ai-chat-frontend-contribution';
|
||||
import { IMAGE_CONTEXT_VARIABLE, ImageContextVariable } from '../common/image-context-variable';
|
||||
import { ApplicationShell } from '@theia/core/lib/browser';
|
||||
|
||||
@injectable()
|
||||
export class FileChatVariableContribution implements FrontendVariableContribution {
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly wsService: WorkspaceService;
|
||||
|
||||
@inject(QuickInputService)
|
||||
protected readonly quickInputService: QuickInputService;
|
||||
|
||||
@inject(QuickFileSelectService)
|
||||
protected readonly quickFileSelectService: QuickFileSelectService;
|
||||
|
||||
@inject(ILogger)
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
registerVariables(service: FrontendVariableService): void {
|
||||
service.registerArgumentPicker(FILE_VARIABLE, this.triggerArgumentPicker.bind(this));
|
||||
service.registerArgumentPicker(IMAGE_CONTEXT_VARIABLE, this.imageArgumentPicker.bind(this));
|
||||
service.registerArgumentCompletionProvider(FILE_VARIABLE, this.provideArgumentCompletionItems.bind(this));
|
||||
service.registerDropHandler(this.handleDrop.bind(this));
|
||||
}
|
||||
|
||||
protected async triggerArgumentPicker(): Promise<string | undefined> {
|
||||
const quickPick = this.quickInputService.createQuickPick();
|
||||
quickPick.items = await this.quickFileSelectService.getPicks();
|
||||
|
||||
const updateItems = async (value: string) => {
|
||||
quickPick.items = await this.quickFileSelectService.getPicks(value, CancellationToken.None);
|
||||
};
|
||||
|
||||
const onChangeListener = quickPick.onDidChangeValue(updateItems);
|
||||
quickPick.show();
|
||||
|
||||
return new Promise(resolve => {
|
||||
quickPick.onDispose(onChangeListener.dispose);
|
||||
quickPick.onDidAccept(async () => {
|
||||
const selectedItem = quickPick.selectedItems[0];
|
||||
if (selectedItem && FileQuickPickItem.is(selectedItem)) {
|
||||
quickPick.dispose();
|
||||
resolve(await this.wsService.getWorkspaceRelativePath(selectedItem.uri));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected async imageArgumentPicker(): Promise<string | undefined> {
|
||||
const quickPick = this.quickInputService.createQuickPick();
|
||||
quickPick.title = nls.localize('theia/ai/chat/selectImageFile', 'Select an image file');
|
||||
|
||||
// Get all files and filter only image files
|
||||
const allPicks = await this.quickFileSelectService.getPicks();
|
||||
quickPick.items = allPicks.filter(item => {
|
||||
if (FileQuickPickItem.is(item)) {
|
||||
return this.isImageFile(item.uri.path.toString());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const updateItems = async (value: string) => {
|
||||
const filteredPicks = await this.quickFileSelectService.getPicks(value, CancellationToken.None);
|
||||
quickPick.items = filteredPicks.filter(item => {
|
||||
if (FileQuickPickItem.is(item)) {
|
||||
return this.isImageFile(item.uri.path.toString());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeListener = quickPick.onDidChangeValue(updateItems);
|
||||
quickPick.show();
|
||||
|
||||
return new Promise(resolve => {
|
||||
quickPick.onDispose(onChangeListener.dispose);
|
||||
quickPick.onDidAccept(async () => {
|
||||
const selectedItem = quickPick.selectedItems[0];
|
||||
if (selectedItem && FileQuickPickItem.is(selectedItem)) {
|
||||
quickPick.dispose();
|
||||
const filePath = await this.wsService.getWorkspaceRelativePath(selectedItem.uri);
|
||||
const fileName = selectedItem.uri.displayName;
|
||||
const base64Data = await this.fileToBase64(selectedItem.uri);
|
||||
if (!base64Data) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
const mimeType = this.getMimeTypeFromExtension(selectedItem.uri.path.toString());
|
||||
|
||||
// Create the argument string in the required format
|
||||
const imageVarArgs: ImageContextVariable = {
|
||||
name: fileName,
|
||||
wsRelativePath: filePath,
|
||||
data: base64Data,
|
||||
mimeType: mimeType,
|
||||
origin: 'context'
|
||||
};
|
||||
|
||||
resolve(ImageContextVariable.createArgString(imageVarArgs));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected async provideArgumentCompletionItems(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
matchString?: string
|
||||
): Promise<monaco.languages.CompletionItem[] | undefined> {
|
||||
const context = AIVariableCompletionContext.get(FILE_VARIABLE.name, model, position, matchString);
|
||||
if (!context) { return undefined; }
|
||||
const { userInput, range, prefix } = context;
|
||||
|
||||
const picks = await this.quickFileSelectService.getPicks(userInput, CancellationToken.None);
|
||||
|
||||
return Promise.all(
|
||||
picks
|
||||
.filter(FileQuickPickItem.is)
|
||||
// only show files with highlights, if the user started typing to filter down the results
|
||||
.filter(p => !userInput || p.highlights?.label)
|
||||
.map(async (pick, index) => {
|
||||
const relativePath = await this.wsService.getWorkspaceRelativePath(pick.uri);
|
||||
return {
|
||||
label: pick.label,
|
||||
kind: monaco.languages.CompletionItemKind.File,
|
||||
range,
|
||||
insertText: `${prefix}${relativePath}`,
|
||||
detail: await this.wsService.getWorkspaceRelativePath(pick.uri.parent),
|
||||
// don't let monaco filter the items, as we only return picks that are filtered
|
||||
filterText: userInput,
|
||||
// keep the order of the items, but move them to the end of the list
|
||||
sortText: `ZZ${index.toString().padStart(4, '0')}_${pick.label}`,
|
||||
command: {
|
||||
title: VARIABLE_ADD_CONTEXT_COMMAND.label!,
|
||||
id: VARIABLE_ADD_CONTEXT_COMMAND.id,
|
||||
arguments: [FILE_VARIABLE.name, relativePath]
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file is an image based on its extension.
|
||||
*/
|
||||
protected isImageFile(filePath: string): boolean {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp'];
|
||||
const extension = filePath.toLowerCase().substring(filePath.lastIndexOf('.'));
|
||||
return imageExtensions.includes(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the MIME type based on file extension.
|
||||
*/
|
||||
protected getMimeTypeFromExtension(filePath: string): string {
|
||||
const extension = filePath.toLowerCase().substring(filePath.lastIndexOf('.'));
|
||||
const mimeTypes: { [key: string]: string } = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
return mimeTypes[extension] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a file to base64 data URL.
|
||||
*/
|
||||
protected async fileToBase64(uri: URI): Promise<string> {
|
||||
try {
|
||||
const fileContent = await this.fileService.readFile(uri);
|
||||
const uint8Array = new Uint8Array(fileContent.value.buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
binary += String.fromCharCode(uint8Array[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
} catch (error) {
|
||||
this.logger.error('Error reading file content:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleDrop(event: DragEvent, _: AIVariableContext): Promise<AIVariableDropResult | undefined> {
|
||||
if (!event.dataTransfer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uris = ApplicationShell.getDraggedEditorUris(event.dataTransfer);
|
||||
if (!uris.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const variables: AIVariableResolutionRequest[] = [];
|
||||
const texts: string[] = [];
|
||||
for (const uri of uris) {
|
||||
if (await this.fileService.exists(uri)) {
|
||||
const wsRelativePath = await this.wsService.getWorkspaceRelativePath(uri);
|
||||
const fileName = uri.displayName;
|
||||
|
||||
if (!wsRelativePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isImageFile(wsRelativePath)) {
|
||||
const base64Data = await this.fileToBase64(uri);
|
||||
if (!base64Data) {
|
||||
continue;
|
||||
}
|
||||
const mimeType = this.getMimeTypeFromExtension(wsRelativePath);
|
||||
variables.push(ImageContextVariable.createRequest({
|
||||
name: fileName,
|
||||
wsRelativePath,
|
||||
data: base64Data,
|
||||
mimeType,
|
||||
origin: 'temporary'
|
||||
}));
|
||||
// we do not want to push a text for image variables
|
||||
} else {
|
||||
variables.push({
|
||||
variable: FILE_VARIABLE,
|
||||
arg: wsRelativePath
|
||||
});
|
||||
texts.push(`${PromptText.VARIABLE_CHAR}${FILE_VARIABLE.name}${PromptText.VARIABLE_SEPARATOR_CHAR}${wsRelativePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { variables, text: texts.length ? texts.join(' ') : undefined };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
packages/ai-chat/src/browser/frontend-chat-service.ts
Normal file
68
packages/ai-chat/src/browser/frontend-chat-service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatAgent, ChatAgentLocation, ChatChangeEvent, ChatServiceImpl, ChatSession, ParsedChatRequest, SessionOptions } from '../common';
|
||||
import { PreferenceService } from '@theia/core/lib/common';
|
||||
import { DEFAULT_CHAT_AGENT_PREF, PIN_CHAT_AGENT_PREF } from '../common/ai-chat-preferences';
|
||||
import { ChangeSetFileService } from './change-set-file-service';
|
||||
|
||||
/**
|
||||
* Customizes the ChatServiceImpl to consider preference based default chat agent
|
||||
*/
|
||||
@injectable()
|
||||
export class FrontendChatServiceImpl extends ChatServiceImpl {
|
||||
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
@inject(ChangeSetFileService)
|
||||
protected readonly changeSetFileService: ChangeSetFileService;
|
||||
|
||||
protected override isPinChatAgentEnabled(): boolean {
|
||||
return this.preferenceService.get<boolean>(PIN_CHAT_AGENT_PREF, true);
|
||||
}
|
||||
|
||||
protected override initialAgentSelection(parsedRequest: ParsedChatRequest): ChatAgent | undefined {
|
||||
const agentPart = this.getMentionedAgent(parsedRequest);
|
||||
if (!agentPart) {
|
||||
const configuredDefaultChatAgent = this.getConfiguredDefaultChatAgent();
|
||||
if (configuredDefaultChatAgent) {
|
||||
return configuredDefaultChatAgent;
|
||||
}
|
||||
}
|
||||
return super.initialAgentSelection(parsedRequest);
|
||||
}
|
||||
|
||||
protected getConfiguredDefaultChatAgent(): ChatAgent | undefined {
|
||||
const configuredDefaultChatAgentId = this.preferenceService.get<string>(DEFAULT_CHAT_AGENT_PREF, undefined);
|
||||
const configuredDefaultChatAgent = configuredDefaultChatAgentId ? this.chatAgentService.getAgent(configuredDefaultChatAgentId) : undefined;
|
||||
if (configuredDefaultChatAgentId && !configuredDefaultChatAgent) {
|
||||
this.logger.warn(`The configured default chat agent with id '${configuredDefaultChatAgentId}' does not exist or is disabled.`);
|
||||
}
|
||||
return configuredDefaultChatAgent;
|
||||
}
|
||||
|
||||
override createSession(location?: ChatAgentLocation, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession {
|
||||
const session = super.createSession(location, options, pinnedAgent);
|
||||
session.model.onDidChange(event => {
|
||||
if (ChatChangeEvent.isChangeSetEvent(event)) {
|
||||
this.changeSetFileService.closeDiffsForSession(session.id, session.model.changeSet.getElements().map(({ uri }) => uri));
|
||||
}
|
||||
});
|
||||
return session;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
AIVariableContext, AIVariableContribution,
|
||||
AIVariableOpener, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable
|
||||
} from '@theia/ai-core';
|
||||
import { FrontendVariableService, AIVariablePasteResult } from '@theia/ai-core/lib/browser';
|
||||
import { Path, URI } from '@theia/core';
|
||||
import { LabelProvider, LabelProviderContribution, open, OpenerService } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import { IMAGE_CONTEXT_VARIABLE, ImageContextVariable, ImageContextVariableRequest } from '../common/image-context-variable';
|
||||
|
||||
@injectable()
|
||||
export class ImageContextVariableContribution implements AIVariableContribution, AIVariableResolver, AIVariableOpener, LabelProviderContribution {
|
||||
@inject(FileService)
|
||||
protected readonly fileService: FileService;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly wsService: WorkspaceService;
|
||||
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
|
||||
@inject(LabelProvider)
|
||||
protected readonly labelProvider: LabelProvider;
|
||||
|
||||
registerVariables(service: FrontendVariableService): void {
|
||||
service.registerResolver(IMAGE_CONTEXT_VARIABLE, this);
|
||||
service.registerOpener(IMAGE_CONTEXT_VARIABLE, this);
|
||||
service.registerPasteHandler(this.handlePaste.bind(this));
|
||||
}
|
||||
|
||||
async canResolve(request: AIVariableResolutionRequest, _: AIVariableContext): Promise<number> {
|
||||
return ImageContextVariable.isImageContextRequest(request) ? 1 : 0;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, _: AIVariableContext): Promise<ResolvedAIContextVariable | undefined> {
|
||||
return ImageContextVariable.resolve(request as ImageContextVariableRequest);
|
||||
}
|
||||
|
||||
async canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<number> {
|
||||
return ImageContextVariable.isImageContextRequest(request) && !!ImageContextVariable.parseRequest(request)?.wsRelativePath ? 1 : 0;
|
||||
}
|
||||
|
||||
async open(request: ImageContextVariableRequest, context: AIVariableContext): Promise<void> {
|
||||
const uri = await this.toUri(request);
|
||||
if (!uri) {
|
||||
throw new Error('Unable to resolve URI for request.');
|
||||
}
|
||||
await open(this.openerService, uri);
|
||||
}
|
||||
|
||||
protected async toUri(request: ImageContextVariableRequest): Promise<URI | undefined> {
|
||||
const variable = ImageContextVariable.parseRequest(request);
|
||||
return variable?.wsRelativePath ? this.makeAbsolute(variable.wsRelativePath) : undefined;
|
||||
}
|
||||
|
||||
async handlePaste(event: ClipboardEvent, context: AIVariableContext): Promise<AIVariablePasteResult | undefined> {
|
||||
if (!event.clipboardData?.items) { return undefined; }
|
||||
|
||||
const variables: AIVariableResolutionRequest[] = [];
|
||||
|
||||
for (const item of event.clipboardData.items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const blob = item.getAsFile();
|
||||
if (blob) {
|
||||
try {
|
||||
const dataUrl = await this.readFileAsDataURL(blob);
|
||||
// Extract the base64 data by removing the data URL prefix
|
||||
// Format is like: data:image/png;base64,BASE64DATA
|
||||
const imageData = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||
variables.push(ImageContextVariable.createRequest({
|
||||
data: imageData,
|
||||
name: blob.name || `pasted-image-${Date.now()}.png`,
|
||||
mimeType: blob.type,
|
||||
origin: 'temporary'
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to process pasted image:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables.length > 0 ? { variables } : undefined;
|
||||
}
|
||||
|
||||
private readFileAsDataURL(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
if (!e.target?.result) {
|
||||
reject(new Error('Failed to read file as data URL'));
|
||||
return;
|
||||
}
|
||||
resolve(e.target.result as string);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
protected async makeAbsolute(pathStr: string): Promise<URI | undefined> {
|
||||
const path = new Path(Path.normalizePathSeparator(pathStr));
|
||||
if (!path.isAbsolute) {
|
||||
const workspaceRoots = this.wsService.tryGetRoots();
|
||||
const wsUris = workspaceRoots.map(root => root.resource.resolve(path));
|
||||
for (const uri of wsUris) {
|
||||
if (await this.fileService.exists(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
const argUri = new URI(pathStr);
|
||||
if (await this.fileService.exists(argUri)) {
|
||||
return argUri;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
canHandle(element: object): number {
|
||||
return ImageContextVariable.isImageContextRequest(element) ? 10 : -1;
|
||||
}
|
||||
|
||||
protected parseArgSafe(arg: string): ImageContextVariable | undefined {
|
||||
try {
|
||||
return ImageContextVariable.parseArg(arg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getIcon(element: ImageContextVariableRequest): string | undefined {
|
||||
const path = this.parseArgSafe(element.arg)?.wsRelativePath;
|
||||
return path ? this.labelProvider.getIcon(new URI(path)) : undefined;
|
||||
}
|
||||
|
||||
getName(element: ImageContextVariableRequest): string | undefined {
|
||||
return this.parseArgSafe(element.arg)?.name;
|
||||
}
|
||||
|
||||
getDetails(element: ImageContextVariableRequest): string | undefined {
|
||||
const path = this.parseArgSafe(element.arg)?.wsRelativePath;
|
||||
return path ? this.labelProvider.getDetails(new URI(path)) : undefined;
|
||||
}
|
||||
}
|
||||
258
packages/ai-chat/src/browser/task-context-service.ts
Normal file
258
packages/ai-chat/src/browser/task-context-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { MaybePromise, ProgressService, URI, generateUuid, Event, EOL, nls } from '@theia/core';
|
||||
import { ChatAgent, ChatAgentLocation, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common';
|
||||
import { PreferenceService } from '@theia/core/lib/common';
|
||||
import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent';
|
||||
import { Deferred } from '@theia/core/lib/common/promise-util';
|
||||
import { AgentService, PromptService, ResolvedPromptFragment } from '@theia/ai-core';
|
||||
import { CHAT_SESSION_SUMMARY_PROMPT } from '../common/chat-session-summary-agent-prompt';
|
||||
import { ChangeSetFileElementFactory } from './change-set-file-element';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
export interface SummaryMetadata {
|
||||
id?: string;
|
||||
label: string;
|
||||
uri?: URI;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface Summary extends SummaryMetadata {
|
||||
summary: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const TaskContextStorageService = Symbol('TaskContextStorageService');
|
||||
export interface TaskContextStorageService {
|
||||
onDidChange: Event<void>;
|
||||
store(summary: Summary): MaybePromise<void>;
|
||||
getAll(): Summary[];
|
||||
get(identifier: string): MaybePromise<Summary | undefined>;
|
||||
delete(identifier: string): MaybePromise<boolean>;
|
||||
open(identifier: string): Promise<void>;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TaskContextService {
|
||||
|
||||
protected pendingSummaries = new Map<string, Promise<Summary>>();
|
||||
|
||||
@inject(ChatService) protected readonly chatService: ChatService;
|
||||
@inject(AgentService) protected readonly agentService: AgentService;
|
||||
@inject(PromptService) protected readonly promptService: PromptService;
|
||||
@inject(TaskContextStorageService) protected readonly storageService: TaskContextStorageService;
|
||||
@inject(ProgressService) protected readonly progressService: ProgressService;
|
||||
@inject(PreferenceService) protected readonly preferenceService: PreferenceService;
|
||||
@inject(ChangeSetFileElementFactory)
|
||||
protected readonly fileChangeFactory: ChangeSetFileElementFactory;
|
||||
|
||||
get onDidChange(): Event<void> {
|
||||
return this.storageService.onDidChange;
|
||||
}
|
||||
|
||||
getAll(): Array<Summary> {
|
||||
return this.storageService.getAll();
|
||||
}
|
||||
|
||||
async getSummary(sessionIdOrFilePath: string): Promise<string> {
|
||||
const existing = await this.storageService.get(sessionIdOrFilePath);
|
||||
if (existing) { return existing.summary; }
|
||||
const pending = this.pendingSummaries.get(sessionIdOrFilePath);
|
||||
if (pending) {
|
||||
return pending.then(({ summary }) => summary);
|
||||
}
|
||||
const session = this.chatService.getSession(sessionIdOrFilePath);
|
||||
if (session) {
|
||||
return this.summarize(session);
|
||||
}
|
||||
throw new Error('Unable to resolve summary request.');
|
||||
}
|
||||
|
||||
/** Returns an ID that can be used to refer to the summary in the future. */
|
||||
async summarize(session: ChatSession, promptId?: string, agent?: ChatAgent, override = true): Promise<string> {
|
||||
const pending = this.pendingSummaries.get(session.id);
|
||||
if (pending) { return pending.then(({ id }) => id); }
|
||||
const existing = this.getSummaryForSession(session);
|
||||
if (existing && !override) { return existing.id; }
|
||||
const summaryId = generateUuid();
|
||||
const summaryDeferred = new Deferred<Summary>();
|
||||
const progress = await this.progressService.showProgress({
|
||||
text: nls.localize('theia/ai/chat/taskContextService/summarizeProgressMessage', 'Summarize: {0}', session.title || session.id),
|
||||
options: { location: 'ai-chat' }
|
||||
});
|
||||
this.pendingSummaries.set(session.id, summaryDeferred.promise);
|
||||
try {
|
||||
const prompt = await this.getSystemPrompt(session, promptId);
|
||||
const newSummary: Summary = {
|
||||
summary: await this.getLlmSummary(session, prompt, agent),
|
||||
label: session.title || session.id,
|
||||
sessionId: session.id,
|
||||
id: summaryId
|
||||
};
|
||||
await this.storageService.store(newSummary);
|
||||
return summaryId;
|
||||
} catch (err) {
|
||||
summaryDeferred.reject(err);
|
||||
const errorSummary: Summary = {
|
||||
summary: `Summary creation failed: ${err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'}`,
|
||||
label: session.title || session.id,
|
||||
sessionId: session.id,
|
||||
id: summaryId
|
||||
};
|
||||
await this.storageService.store(errorSummary);
|
||||
throw err;
|
||||
} finally {
|
||||
progress.cancel();
|
||||
this.pendingSummaries.delete(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
async update(session: ChatSession, promptId?: string, agent?: ChatAgent, override = true): Promise<string> {
|
||||
// Get the existing summary for the session
|
||||
const existingSummary = this.getSummaryForSession(session);
|
||||
if (!existingSummary) {
|
||||
// If no summary exists, create one instead
|
||||
// TODO: Maybe we could also look into the task context folder and ask for the existing ones with an additional menu to create a new one?
|
||||
return this.summarize(session, promptId, agent, override);
|
||||
}
|
||||
|
||||
const progress = await this.progressService.showProgress({
|
||||
text: nls.localize('theia/ai/chat/taskContextService/updatingProgressMessage', 'Updating: {0}', session.title || session.id),
|
||||
options: { location: 'ai-chat' }
|
||||
});
|
||||
try {
|
||||
const prompt = await this.getSystemPrompt(session, promptId);
|
||||
if (!prompt) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the task context file path
|
||||
const taskContextStorageDirectory = this.preferenceService.get(
|
||||
// preference key is defined in TASK_CONTEXT_STORAGE_DIRECTORY_PREF in @theia/ai-ide
|
||||
'ai-features.promptTemplates.taskContextStorageDirectory',
|
||||
'.prompts/task-contexts'
|
||||
);
|
||||
const taskContextFileVariable = session.model.context.getVariables().find(variableReq => variableReq.variable.id === 'file-provider' &&
|
||||
typeof variableReq.arg === 'string' &&
|
||||
(variableReq.arg.startsWith(taskContextStorageDirectory)));
|
||||
|
||||
// Check if we have a document path to update
|
||||
if (taskContextFileVariable && typeof taskContextFileVariable.arg === 'string') {
|
||||
// Set document path in prompt template
|
||||
const documentPath = taskContextFileVariable.arg;
|
||||
|
||||
// Modify prompt to include the document path and content
|
||||
prompt.text = prompt.text + '\nThe document to update is: ' + documentPath + '\n\n## Current Document Content\n\n' + existingSummary.summary;
|
||||
|
||||
// Get updated document content from LLM
|
||||
const updatedDocumentContent = await this.getLlmSummary(session, prompt, agent);
|
||||
|
||||
if (existingSummary.uri) {
|
||||
// updated document metadata shall be updated.
|
||||
// otherwise, frontmatter won't be set
|
||||
const frontmatter = {
|
||||
sessionId: existingSummary.sessionId,
|
||||
date: new Date().toISOString(),
|
||||
label: existingSummary.label,
|
||||
};
|
||||
const content = yaml.dump(frontmatter).trim() + `${EOL}---${EOL}` + updatedDocumentContent;
|
||||
|
||||
session.model.changeSet.addElements(this.fileChangeFactory({
|
||||
uri: existingSummary.uri,
|
||||
type: 'modify',
|
||||
state: 'pending',
|
||||
targetState: content,
|
||||
requestId: session.model.id, // not a request id, as no changeRequest made yet.
|
||||
chatSessionId: session.id
|
||||
}));
|
||||
} else {
|
||||
const updatedSummary: Summary = {
|
||||
...existingSummary,
|
||||
summary: updatedDocumentContent
|
||||
};
|
||||
|
||||
// Store the updated summary
|
||||
await this.storageService.store(updatedSummary);
|
||||
}
|
||||
return existingSummary.id;
|
||||
} else {
|
||||
// Fall back to standard update if no document path is found
|
||||
const updatedSummaryText = await this.getLlmSummary(session, prompt, agent);
|
||||
const updatedSummary: Summary = {
|
||||
...existingSummary,
|
||||
summary: updatedSummaryText
|
||||
};
|
||||
await this.storageService.store(updatedSummary);
|
||||
return updatedSummary.id;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorSummary: Summary = {
|
||||
...existingSummary,
|
||||
summary: `Summary update failed: ${err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'}`
|
||||
};
|
||||
await this.storageService.store(errorSummary);
|
||||
throw err;
|
||||
} finally {
|
||||
progress.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
protected async getLlmSummary(session: ChatSession, prompt: ResolvedPromptFragment | undefined, agent?: ChatAgent): Promise<string> {
|
||||
if (!prompt) { return ''; }
|
||||
agent = agent || this.agentService.getAgents().find<ChatAgent>((candidate): candidate is ChatAgent =>
|
||||
'invoke' in candidate
|
||||
&& typeof candidate.invoke === 'function'
|
||||
&& candidate.id === ChatSessionSummaryAgent.ID
|
||||
);
|
||||
if (!agent) { throw new Error('Unable to identify agent for summary.'); }
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
|
||||
const messages = session.model.getRequests().filter((candidate): candidate is MutableChatRequestModel => candidate instanceof MutableChatRequestModel);
|
||||
messages.forEach(message => model['_hierarchy'].append(message));
|
||||
const summaryRequest = model.addRequest({
|
||||
variables: prompt.variables ?? [],
|
||||
request: { text: prompt.text },
|
||||
parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: prompt.text.length }, prompt.text)],
|
||||
toolRequests: prompt.functionDescriptions ?? new Map()
|
||||
}, agent.id);
|
||||
await agent.invoke(summaryRequest);
|
||||
return summaryRequest.response.response.asDisplayString();
|
||||
}
|
||||
|
||||
protected async getSystemPrompt(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id): Promise<ResolvedPromptFragment | undefined> {
|
||||
const prompt = await this.promptService.getResolvedPromptFragment(promptId || CHAT_SESSION_SUMMARY_PROMPT.id, undefined, { model: session.model });
|
||||
return prompt;
|
||||
}
|
||||
|
||||
hasSummary(chatSession: ChatSession): boolean {
|
||||
return !!this.getSummaryForSession(chatSession);
|
||||
}
|
||||
|
||||
protected getSummaryForSession(chatSession: ChatSession): Summary | undefined {
|
||||
return this.storageService.getAll().find(candidate => candidate.sessionId === chatSession.id);
|
||||
}
|
||||
|
||||
getLabel(id: string): string | undefined {
|
||||
// Labels are metadata that don't need fresh file reads
|
||||
return this.storageService.getAll().find(s => s.id === id)?.label;
|
||||
}
|
||||
|
||||
open(id: string): Promise<void> {
|
||||
return this.storageService.open(id);
|
||||
}
|
||||
}
|
||||
79
packages/ai-chat/src/browser/task-context-storage-service.ts
Normal file
79
packages/ai-chat/src/browser/task-context-storage-service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { Summary, TaskContextStorageService } from './task-context-service';
|
||||
import { Emitter } from '@theia/core';
|
||||
import { AIVariableResourceResolver } from '@theia/ai-core';
|
||||
import { TASK_CONTEXT_VARIABLE } from './task-context-variable';
|
||||
import { open, OpenerService } from '@theia/core/lib/browser';
|
||||
|
||||
@injectable()
|
||||
export class InMemoryTaskContextStorage implements TaskContextStorageService {
|
||||
protected summaries = new Map<string, Summary>();
|
||||
|
||||
protected readonly onDidChangeEmitter = new Emitter<void>();
|
||||
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
|
||||
protected sanitizeLabel(label: string): string {
|
||||
return label.replace(/^[^\p{L}\p{N}]+/ug, '');
|
||||
}
|
||||
|
||||
@inject(AIVariableResourceResolver)
|
||||
protected readonly variableResourceResolver: AIVariableResourceResolver;
|
||||
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
|
||||
store(summary: Summary): void {
|
||||
this.summaries.set(summary.id, { ...summary, label: this.sanitizeLabel(summary.label) });
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
|
||||
getAll(): Summary[] {
|
||||
return Array.from(this.summaries.values());
|
||||
}
|
||||
|
||||
get(identifier: string): Summary | undefined {
|
||||
return this.summaries.get(identifier);
|
||||
}
|
||||
|
||||
delete(identifier: string): boolean {
|
||||
const didDelete = this.summaries.delete(identifier);
|
||||
if (didDelete) {
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
return didDelete;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.summaries.size) {
|
||||
this.summaries.clear();
|
||||
this.onDidChangeEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
async open(identifier: string): Promise<void> {
|
||||
const summary = this.get(identifier);
|
||||
if (!summary) {
|
||||
throw new Error('Unable to upon requested task context: none found.');
|
||||
}
|
||||
const resource = this.variableResourceResolver.getOrCreate({ variable: TASK_CONTEXT_VARIABLE, arg: identifier }, {}, summary.summary);
|
||||
resource.update({ onSave: async content => { summary.summary = content; }, readOnly: false });
|
||||
await open(this.openerService, resource.uri);
|
||||
resource.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { AIVariableContext, AIVariableOpener, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable } from '@theia/ai-core';
|
||||
import { AIVariableCompletionContext, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser';
|
||||
import { MaybePromise, QuickInputService, QuickPickItem } from '@theia/core';
|
||||
import { ChatService } from '../common';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
import { TaskContextService } from './task-context-service';
|
||||
import { TASK_CONTEXT_VARIABLE } from './task-context-variable';
|
||||
import { VARIABLE_ADD_CONTEXT_COMMAND } from './ai-chat-frontend-contribution';
|
||||
|
||||
@injectable()
|
||||
export class TaskContextVariableContribution implements FrontendVariableContribution, AIVariableResolver, AIVariableOpener {
|
||||
@inject(QuickInputService) protected readonly quickInputService: QuickInputService;
|
||||
@inject(ChatService) protected readonly chatService: ChatService;
|
||||
@inject(TaskContextService) protected readonly taskContextService: TaskContextService;
|
||||
|
||||
registerVariables(service: FrontendVariableService): void {
|
||||
service.registerResolver(TASK_CONTEXT_VARIABLE, this);
|
||||
service.registerArgumentPicker(TASK_CONTEXT_VARIABLE, this.pickSession.bind(this));
|
||||
service.registerArgumentCompletionProvider(TASK_CONTEXT_VARIABLE, this.provideCompletionItems.bind(this));
|
||||
service.registerOpener(TASK_CONTEXT_VARIABLE, this);
|
||||
}
|
||||
|
||||
protected async pickSession(): Promise<string | undefined> {
|
||||
const items = this.getItems();
|
||||
const selection = await this.quickInputService.showQuickPick(items);
|
||||
return selection?.id;
|
||||
}
|
||||
|
||||
protected async provideCompletionItems(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
matchString?: string
|
||||
): Promise<monaco.languages.CompletionItem[] | undefined> {
|
||||
const context = AIVariableCompletionContext.get(TASK_CONTEXT_VARIABLE.name, model, position, matchString);
|
||||
if (!context) { return undefined; }
|
||||
const { userInput, range, prefix } = context;
|
||||
return this.getItems().filter(candidate => QuickPickItem.is(candidate) && candidate.label.startsWith(userInput)).map(({ label, id }: QuickPickItem) => ({
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
range,
|
||||
insertText: `${prefix}${id}`,
|
||||
detail: id,
|
||||
filterText: userInput,
|
||||
command: {
|
||||
title: VARIABLE_ADD_CONTEXT_COMMAND.label!,
|
||||
id: VARIABLE_ADD_CONTEXT_COMMAND.id,
|
||||
arguments: [TASK_CONTEXT_VARIABLE.name, id]
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
protected getItems(): QuickPickItem[] {
|
||||
const currentSession = this.chatService.getSessions().find(candidate => candidate.isActive);
|
||||
const existingSummaries = this.taskContextService.getAll().filter(candidate => !currentSession || currentSession.id !== candidate.sessionId);
|
||||
return existingSummaries;
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
|
||||
return request.variable.id === TASK_CONTEXT_VARIABLE.id ? 10000 : -5;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise<ResolvedAIContextVariable | undefined> {
|
||||
if (request.variable.id !== TASK_CONTEXT_VARIABLE.id || !request.arg) { return; }
|
||||
const value = await this.taskContextService.getSummary(request.arg).catch(() => undefined);
|
||||
return value ? { ...request, value, contextValue: value } : undefined;
|
||||
}
|
||||
|
||||
canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
|
||||
return this.canResolve(request, context);
|
||||
}
|
||||
|
||||
async open(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise<void> {
|
||||
if (request.variable.id !== TASK_CONTEXT_VARIABLE.id || !request.arg) { throw new Error('Unable to service open request.'); }
|
||||
return this.taskContextService.open(request.arg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 Eclipse GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIVariableResolutionRequest } from '@theia/ai-core';
|
||||
import { URI } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { codicon, LabelProviderContribution } from '@theia/core/lib/browser';
|
||||
import { TaskContextVariableContribution } from './task-context-variable-contribution';
|
||||
import { ChatService } from '../common';
|
||||
import { TaskContextService } from './task-context-service';
|
||||
import { TASK_CONTEXT_VARIABLE } from './task-context-variable';
|
||||
|
||||
@injectable()
|
||||
export class TaskContextVariableLabelProvider implements LabelProviderContribution {
|
||||
@inject(ChatService) protected readonly chatService: ChatService;
|
||||
@inject(TaskContextVariableContribution) protected readonly chatVariableContribution: TaskContextVariableContribution;
|
||||
@inject(TaskContextService) protected readonly taskContextService: TaskContextService;
|
||||
protected isMine(element: object): element is AIVariableResolutionRequest & { arg: string } {
|
||||
return AIVariableResolutionRequest.is(element) && element.variable.id === TASK_CONTEXT_VARIABLE.id && !!element.arg;
|
||||
}
|
||||
|
||||
canHandle(element: object): number {
|
||||
return this.isMine(element) ? 10 : -1;
|
||||
}
|
||||
|
||||
getIcon(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
return codicon('clippy');
|
||||
}
|
||||
|
||||
getName(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
const session = this.chatService.getSession(element.arg);
|
||||
return session?.title ?? this.taskContextService.getLabel(element.arg) ?? session?.id ?? element.arg;
|
||||
}
|
||||
|
||||
getLongName(element: object): string | undefined {
|
||||
const short = this.getName(element);
|
||||
const details = this.getDetails(element);
|
||||
return `'${short}' (${details})`;
|
||||
}
|
||||
|
||||
getDetails(element: object): string | undefined {
|
||||
if (!this.isMine(element)) { return undefined; }
|
||||
return `id: ${element.arg}`;
|
||||
}
|
||||
|
||||
protected getUri(element: object): URI | undefined {
|
||||
if (!AIVariableResolutionRequest.is(element)) {
|
||||
return undefined;
|
||||
}
|
||||
return new URI(element.arg);
|
||||
}
|
||||
}
|
||||
33
packages/ai-chat/src/browser/task-context-variable.ts
Normal file
33
packages/ai-chat/src/browser/task-context-variable.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIVariable } from '@theia/ai-core';
|
||||
import { nls } from '@theia/core';
|
||||
import { codiconArray } from '@theia/core/lib/browser';
|
||||
|
||||
export const TASK_CONTEXT_VARIABLE: AIVariable = {
|
||||
id: 'taskContext',
|
||||
description: nls.localize('theia/chat/taskContextVariable/description',
|
||||
'Provides context information for a task, e.g. the plan for completing a task or a summary of a previous sessions'),
|
||||
name: 'taskContext',
|
||||
label: nls.localize('theia/chat/taskContextVariable/label', 'Task Context'),
|
||||
iconClasses: codiconArray('clippy'),
|
||||
isContextVariable: true,
|
||||
args: [{
|
||||
name: 'context-id',
|
||||
description: nls.localize('theia/chat/taskContextVariable/args/contextId/description', 'The ID of the task context to retrieve, or a chat session to summarize.')
|
||||
}]
|
||||
};
|
||||
77
packages/ai-chat/src/common/ai-chat-preferences.ts
Normal file
77
packages/ai-chat/src/common/ai-chat-preferences.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences';
|
||||
import { nls, PreferenceSchema } from '@theia/core';
|
||||
|
||||
export const DEFAULT_CHAT_AGENT_PREF = 'ai-features.chat.defaultChatAgent';
|
||||
export const PIN_CHAT_AGENT_PREF = 'ai-features.chat.pinChatAgent';
|
||||
export const BYPASS_MODEL_REQUIREMENT_PREF = 'ai-features.chat.bypassModelRequirement';
|
||||
export const PERSISTED_SESSION_LIMIT_PREF = 'ai-features.chat.persistedSessionLimit';
|
||||
export const SESSION_STORAGE_PREF = 'ai-features.chat.sessionStorageScope';
|
||||
|
||||
export type SessionStorageScope = 'workspace' | 'global';
|
||||
|
||||
export const aiChatPreferences: PreferenceSchema = {
|
||||
properties: {
|
||||
[DEFAULT_CHAT_AGENT_PREF]: {
|
||||
type: 'string',
|
||||
description: nls.localize('theia/ai/chat/defaultAgent/description',
|
||||
'Optional: <agent-name> of the Chat Agent that shall be invoked, if no agent is explicitly mentioned with @<agent-name> in the user query. \
|
||||
If no Default Agent is configured, Theia´s defaults will be applied.'),
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
},
|
||||
[PIN_CHAT_AGENT_PREF]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('theia/ai/chat/pinChatAgent/description',
|
||||
'Enable agent pinning to automatically keep a mentioned chat agent active across prompts, reducing the need for repeated mentions.\
|
||||
You can manually unpin or switch agents anytime.'),
|
||||
default: true,
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
},
|
||||
[BYPASS_MODEL_REQUIREMENT_PREF]: {
|
||||
type: 'boolean',
|
||||
description: nls.localize('theia/ai/chat/bypassModelRequirement/description',
|
||||
'Bypass the language model requirement check. Enable this if you are using external agents (e.g., Claude Code) that do not require Theia language models.'),
|
||||
default: false,
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
},
|
||||
[PERSISTED_SESSION_LIMIT_PREF]: {
|
||||
type: 'number',
|
||||
description: nls.localize('theia/ai/chat/persistedSessionLimit/description',
|
||||
'Maximum number of chat sessions to persist. Use -1 for unlimited sessions, 0 to disable session persistence. ' +
|
||||
'When the limit is reduced, the oldest sessions exceeding the new limit are automatically removed on the next save.'),
|
||||
default: 25,
|
||||
minimum: -1,
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
},
|
||||
[SESSION_STORAGE_PREF]: {
|
||||
type: 'string',
|
||||
enum: ['workspace', 'global'] satisfies SessionStorageScope[],
|
||||
enumDescriptions: [
|
||||
nls.localize('theia/ai/chat/sessionStorageScope/workspace',
|
||||
'Store chat sessions in workspace-specific metadata storage. Sessions are associated with the workspace but stored outside the workspace directory.'),
|
||||
nls.localize('theia/ai/chat/sessionStorageScope/global',
|
||||
'Store chat sessions in a single store, shared across all workspaces.')
|
||||
],
|
||||
default: 'workspace' as SessionStorageScope,
|
||||
description: nls.localize('theia/ai/chat/sessionStorageScope/description',
|
||||
'Choose whether to persist chat sessions in separate per-workspace stores or in a single global store. ' +
|
||||
'If no workspace is open, sessions will fall back to global storage.'),
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { ContributionProvider, MaybePromise, URI } from '@theia/core';
|
||||
import { ChangeSetElement } from './change-set';
|
||||
import { SerializableChangeSetElement } from './chat-model-serialization';
|
||||
|
||||
export const ChangeSetElementDeserializer = Symbol('ChangeSetElementDeserializer');
|
||||
|
||||
export interface ChangeSetElementDeserializer<T = unknown> {
|
||||
readonly kind: string;
|
||||
deserialize(serialized: SerializableChangeSetElement, context: ChangeSetDeserializationContext): ChangeSetElement | Promise<ChangeSetElement>;
|
||||
}
|
||||
|
||||
export interface ChangeSetDeserializationContext {
|
||||
chatSessionId: string;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ChangeSetElementDeserializerContribution {
|
||||
registerDeserializers(registry: ChangeSetElementDeserializerRegistry): void;
|
||||
}
|
||||
export const ChangeSetElementDeserializerContribution = Symbol('ChangeSetElementDeserializerContribution');
|
||||
export interface ChangeSetElementDeserializerRegistry {
|
||||
register(deserializer: ChangeSetElementDeserializer): void;
|
||||
deserialize(serialized: SerializableChangeSetElement, context: ChangeSetDeserializationContext): MaybePromise<ChangeSetElement>;
|
||||
}
|
||||
export const ChangeSetElementDeserializerRegistry = Symbol('ChangeSetElementDeserializerRegistry');
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetElementDeserializerRegistryImpl implements ChangeSetElementDeserializerRegistry {
|
||||
protected deserializers = new Map<string, ChangeSetElementDeserializer>();
|
||||
|
||||
@inject(ContributionProvider) @named(ChangeSetElementDeserializerContribution)
|
||||
protected readonly changeSetElementDeserializerContributions: ContributionProvider<ChangeSetElementDeserializerContribution>;
|
||||
|
||||
@postConstruct() init(): void {
|
||||
for (const contribution of this.changeSetElementDeserializerContributions.getContributions()) {
|
||||
contribution.registerDeserializers(this);
|
||||
}
|
||||
}
|
||||
|
||||
register(deserializer: ChangeSetElementDeserializer): void {
|
||||
this.deserializers.set(deserializer.kind, deserializer);
|
||||
}
|
||||
|
||||
deserialize(serialized: SerializableChangeSetElement, context: ChangeSetDeserializationContext): MaybePromise<ChangeSetElement> {
|
||||
const deserializer = this.deserializers.get(serialized.kind || 'generic');
|
||||
if (!deserializer) {
|
||||
return this.createFallbackElement(serialized);
|
||||
}
|
||||
return deserializer.deserialize(serialized, context);
|
||||
}
|
||||
|
||||
private createFallbackElement(serialized: SerializableChangeSetElement): ChangeSetElement {
|
||||
return {
|
||||
uri: new URI(serialized.uri),
|
||||
name: serialized.name,
|
||||
icon: serialized.icon,
|
||||
additionalInfo: serialized.additionalInfo,
|
||||
state: serialized.state,
|
||||
type: serialized.type,
|
||||
data: serialized.data,
|
||||
toSerializable: (): SerializableChangeSetElement => ({
|
||||
kind: serialized.kind || 'generic',
|
||||
uri: serialized.uri,
|
||||
name: serialized.name,
|
||||
icon: serialized.icon,
|
||||
additionalInfo: serialized.additionalInfo,
|
||||
state: serialized.state,
|
||||
type: serialized.type,
|
||||
data: serialized.data
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
205
packages/ai-chat/src/common/change-set.ts
Normal file
205
packages/ai-chat/src/common/change-set.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ArrayUtils, Disposable, Emitter, Event, nls, URI } from '@theia/core';
|
||||
import { SerializableChangeSetElement } from './chat-model-serialization';
|
||||
|
||||
export interface ChangeSetElement {
|
||||
readonly uri: URI;
|
||||
|
||||
onDidChange?: Event<void>
|
||||
readonly name?: string;
|
||||
readonly icon?: string;
|
||||
readonly additionalInfo?: string;
|
||||
|
||||
readonly state?: 'pending' | 'applied' | 'stale';
|
||||
readonly type?: 'add' | 'modify' | 'delete';
|
||||
readonly data?: { [key: string]: unknown };
|
||||
|
||||
/** Called when an element is shown in the UI */
|
||||
onShow?(): void;
|
||||
/** Called when an element is hidden in the UI */
|
||||
onHide?(): void;
|
||||
open?(): Promise<void>;
|
||||
openChange?(): Promise<void>;
|
||||
apply?(): Promise<void>;
|
||||
revert?(): Promise<void>;
|
||||
dispose?(): void;
|
||||
|
||||
/**
|
||||
* Serializes this element to a format suitable for persistence.
|
||||
* Each element type is responsible for serializing its own data.
|
||||
* Optional - elements without this method will be excluded from serialization.
|
||||
*/
|
||||
toSerializable?(): SerializableChangeSetElement;
|
||||
}
|
||||
|
||||
export interface ChatUpdateChangeSetEvent {
|
||||
kind: 'updateChangeSet';
|
||||
elements?: ChangeSetElement[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ChangeSetChangeEvent {
|
||||
title?: string;
|
||||
added?: URI[],
|
||||
removed?: URI[],
|
||||
modified?: URI[],
|
||||
/** Fired when only the state of a given element changes, not its contents */
|
||||
state?: URI[],
|
||||
}
|
||||
|
||||
export interface ChangeSet extends Disposable {
|
||||
onDidChange: Event<ChatUpdateChangeSetEvent>;
|
||||
readonly title: string;
|
||||
setTitle(title: string): void;
|
||||
getElements(): ChangeSetElement[];
|
||||
/**
|
||||
* Find an element by URI.
|
||||
* @param uri The URI to look for.
|
||||
* @returns The element with the given URI, or undefined if not found.
|
||||
*/
|
||||
getElementByURI(uri: URI): ChangeSetElement | undefined;
|
||||
/** @returns true if addition produces a change; false otherwise. */
|
||||
addElements(...elements: ChangeSetElement[]): boolean;
|
||||
setElements(...elements: ChangeSetElement[]): void;
|
||||
/** @returns true if deletion produces a change; false otherwise. */
|
||||
removeElements(...uris: URI[]): boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class ChangeSetImpl implements ChangeSet {
|
||||
/** @param changeSets ordered from tip to root. */
|
||||
static combine(changeSets: Iterable<ChangeSetImpl>): Map<string, ChangeSetElement | undefined> {
|
||||
const result = new Map<string, ChangeSetElement | undefined>();
|
||||
for (const next of changeSets) {
|
||||
next._elements.forEach((value, key) => !result.has(key) && result.set(key, value));
|
||||
// Break at the first element whose values were set, not just changed through addition and deletion.
|
||||
if (next.hasBeenSet) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected readonly _onDidChangeEmitter = new Emitter<ChatUpdateChangeSetEvent>();
|
||||
onDidChange: Event<ChatUpdateChangeSetEvent> = this._onDidChangeEmitter.event;
|
||||
protected readonly _onDidChangeContentsEmitter = new Emitter<ChangeSetChangeEvent>();
|
||||
onDidChangeContents: Event<ChangeSetChangeEvent> = this._onDidChangeContentsEmitter.event;
|
||||
|
||||
protected hasBeenSet = false;
|
||||
protected _elements = new Map<string, ChangeSetElement | undefined>();
|
||||
protected _title = nls.localize('theia/ai/chat/changeSetDefaultTitle', 'Suggested Changes');
|
||||
get title(): string {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
constructor(elements: ChangeSetElement[] = []) {
|
||||
this.addElements(...elements);
|
||||
}
|
||||
|
||||
getElements(): ChangeSetElement[] {
|
||||
return ArrayUtils.coalesce(Array.from(this._elements.values()));
|
||||
}
|
||||
|
||||
/** Will replace any element that is already present, using URI as identity criterion. */
|
||||
addElements(...elements: ChangeSetElement[]): boolean {
|
||||
const added: URI[] = [];
|
||||
const modified: URI[] = [];
|
||||
elements.forEach(element => {
|
||||
if (this.doAdd(element)) {
|
||||
modified.push(element.uri);
|
||||
} else {
|
||||
added.push(element.uri);
|
||||
}
|
||||
});
|
||||
this.notifyChange({ added, modified });
|
||||
return !!(added.length || modified.length);
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
this._title = title;
|
||||
this.notifyChange({ title });
|
||||
}
|
||||
|
||||
protected doAdd(element: ChangeSetElement): boolean {
|
||||
const id = element.uri.toString();
|
||||
const existing = this._elements.get(id);
|
||||
existing?.dispose?.();
|
||||
this._elements.set(id, element);
|
||||
element.onDidChange?.(() => this.notifyChange({ state: [element.uri] }));
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
setElements(...elements: ChangeSetElement[]): void {
|
||||
this.hasBeenSet = true;
|
||||
const added = [];
|
||||
const modified = [];
|
||||
const removed = [];
|
||||
const toHandle = new Set(this._elements.keys());
|
||||
for (const element of elements) {
|
||||
toHandle.delete(element.uri.toString());
|
||||
if (this.doAdd(element)) {
|
||||
added.push(element.uri);
|
||||
} else {
|
||||
modified.push(element.uri);
|
||||
}
|
||||
}
|
||||
for (const toDelete of toHandle) {
|
||||
const uri = new URI(toDelete);
|
||||
if (this.doDelete(uri)) {
|
||||
removed.push(uri);
|
||||
}
|
||||
}
|
||||
this.notifyChange({ added, modified, removed });
|
||||
}
|
||||
|
||||
removeElements(...uris: URI[]): boolean {
|
||||
const removed: URI[] = [];
|
||||
for (const uri of uris) {
|
||||
if (this.doDelete(uri)) {
|
||||
removed.push(uri);
|
||||
}
|
||||
}
|
||||
this.notifyChange({ removed });
|
||||
return !!removed.length;
|
||||
}
|
||||
|
||||
getElementByURI(uri: URI): ChangeSetElement | undefined {
|
||||
return this._elements.get(uri.toString());
|
||||
}
|
||||
|
||||
protected doDelete(uri: URI): boolean {
|
||||
const id = uri.toString();
|
||||
const delendum = this._elements.get(id);
|
||||
if (delendum) {
|
||||
delendum.dispose?.();
|
||||
}
|
||||
this._elements.set(id, undefined);
|
||||
return !!delendum;
|
||||
}
|
||||
|
||||
protected notifyChange(change: ChangeSetChangeEvent): void {
|
||||
this._onDidChangeContentsEmitter.fire(change);
|
||||
this._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: this.getElements(), title: this.title });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidChangeEmitter.dispose();
|
||||
this._elements.forEach(element => element?.dispose?.());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export const ChatAgentRecommendationService = Symbol('ChatAgentRecommendationService');
|
||||
|
||||
export interface RecommendedAgent {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service that provides recommended chat agents to be displayed in the welcome screen.
|
||||
* This allows different Theia-based products to customize which agents are shown as quick actions.
|
||||
*/
|
||||
export interface ChatAgentRecommendationService {
|
||||
/**
|
||||
* Returns the list of recommended agents to display in the welcome screen.
|
||||
* These agents will be shown as quick-action buttons that users can click to set as default.
|
||||
*/
|
||||
getRecommendedAgents(): RecommendedAgent[];
|
||||
}
|
||||
101
packages/ai-chat/src/common/chat-agent-service.ts
Normal file
101
packages/ai-chat/src/common/chat-agent-service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts
|
||||
|
||||
import { ContributionProvider, ILogger } from '@theia/core';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import { ChatAgent } from './chat-agents';
|
||||
import { AgentService } from '@theia/ai-core';
|
||||
|
||||
export const ChatAgentService = Symbol('ChatAgentService');
|
||||
export const ChatAgentServiceFactory = Symbol('ChatAgentServiceFactory');
|
||||
/**
|
||||
* The ChatAgentService provides access to the available chat agents.
|
||||
*/
|
||||
export interface ChatAgentService {
|
||||
/**
|
||||
* Returns all available agents.
|
||||
*/
|
||||
getAgents(): ChatAgent[];
|
||||
/**
|
||||
* Returns the specified agent, if available
|
||||
*/
|
||||
getAgent(id: string): ChatAgent | undefined;
|
||||
/**
|
||||
* Returns all agents, including disabled ones.
|
||||
*/
|
||||
getAllAgents(): ChatAgent[];
|
||||
|
||||
/**
|
||||
* Allows to register a chat agent programmatically.
|
||||
* @param agent the agent to register
|
||||
*/
|
||||
registerChatAgent(agent: ChatAgent): void;
|
||||
|
||||
/**
|
||||
* Allows to unregister a chat agent programmatically.
|
||||
* @param agentId the agent id to unregister
|
||||
*/
|
||||
unregisterChatAgent(agentId: string): void;
|
||||
}
|
||||
@injectable()
|
||||
export class ChatAgentServiceImpl implements ChatAgentService {
|
||||
|
||||
@inject(ContributionProvider) @named(ChatAgent)
|
||||
protected readonly agentContributions: ContributionProvider<ChatAgent>;
|
||||
|
||||
@inject(ILogger)
|
||||
protected logger: ILogger;
|
||||
|
||||
@inject(AgentService)
|
||||
protected agentService: AgentService;
|
||||
|
||||
protected _agents: ChatAgent[] = [];
|
||||
|
||||
protected get agents(): ChatAgent[] {
|
||||
// We can't collect the contributions at @postConstruct because this will lead to a circular dependency
|
||||
// with chat agents reusing the chat agent service (e.g. orchestrator)
|
||||
return [...this.agentContributions.getContributions(), ...this._agents];
|
||||
}
|
||||
|
||||
registerChatAgent(agent: ChatAgent): void {
|
||||
this._agents.push(agent);
|
||||
}
|
||||
unregisterChatAgent(agentId: string): void {
|
||||
this._agents = this._agents.filter(a => a.id !== agentId);
|
||||
}
|
||||
|
||||
getAgent(id: string): ChatAgent | undefined {
|
||||
if (!this._agentIsEnabled(id)) {
|
||||
return undefined;
|
||||
}
|
||||
return this.getAgents().find(agent => agent.id === id);
|
||||
}
|
||||
getAgents(): ChatAgent[] {
|
||||
return this.agents.filter(a => this._agentIsEnabled(a.id));
|
||||
}
|
||||
getAllAgents(): ChatAgent[] {
|
||||
return this.agents;
|
||||
}
|
||||
|
||||
private _agentIsEnabled(id: string): boolean {
|
||||
return this.agentService.isEnabled(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
AIVariable,
|
||||
AIVariableContext,
|
||||
AIVariableContribution,
|
||||
AIVariableResolutionRequest,
|
||||
AIVariableResolver,
|
||||
AIVariableService,
|
||||
ResolvedAIVariable
|
||||
} from '@theia/ai-core';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
|
||||
export const CHAT_AGENTS_VARIABLE: AIVariable = {
|
||||
id: 'chatAgents',
|
||||
name: 'chatAgents',
|
||||
description: nls.localize('theia/ai/chat/chatAgentsVariable/description', 'Returns the list of chat agents available in the system')
|
||||
};
|
||||
|
||||
export interface ChatAgentDescriptor {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChatAgentsVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
|
||||
@inject(ChatAgentService)
|
||||
protected readonly agents: ChatAgentService;
|
||||
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(CHAT_AGENTS_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, _context: AIVariableContext): MaybePromise<number> {
|
||||
if (request.variable.name === CHAT_AGENTS_VARIABLE.name) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
if (request.variable.name === CHAT_AGENTS_VARIABLE.name) {
|
||||
return this.resolveAgentsVariable(request);
|
||||
}
|
||||
}
|
||||
|
||||
resolveAgentsVariable(_request: AIVariableResolutionRequest): ResolvedAIVariable {
|
||||
const agents = this.agents.getAgents().map(agent => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description
|
||||
}));
|
||||
const value = agents.map(agent => prettyPrintInMd(agent)).join('\n');
|
||||
return { variable: CHAT_AGENTS_VARIABLE, value };
|
||||
}
|
||||
}
|
||||
|
||||
function prettyPrintInMd(agent: { id: string; name: string; description: string; }): string {
|
||||
return `- ${agent.id}
|
||||
- *ID*: ${agent.id}
|
||||
- *Name*: ${agent.name}
|
||||
- *Description*: ${agent.description.replace(/\n/g, ' ')}`;
|
||||
}
|
||||
|
||||
563
packages/ai-chat/src/common/chat-agents.ts
Normal file
563
packages/ai-chat/src/common/chat-agents.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts
|
||||
|
||||
import {
|
||||
AgentSpecificVariables,
|
||||
AIVariableContext,
|
||||
AIVariableResolutionRequest,
|
||||
getTextOfResponse,
|
||||
isLanguageModelStreamResponsePart,
|
||||
isTextResponsePart,
|
||||
isThinkingResponsePart,
|
||||
isToolCallResponsePart,
|
||||
isUsageResponsePart,
|
||||
LanguageModel,
|
||||
LanguageModelMessage,
|
||||
LanguageModelRequirement,
|
||||
LanguageModelResponse,
|
||||
LanguageModelService,
|
||||
LanguageModelStreamResponse,
|
||||
PromptService,
|
||||
ResolvedPromptFragment,
|
||||
PromptVariantSet,
|
||||
TextMessage,
|
||||
ToolCall,
|
||||
ToolRequest,
|
||||
} from '@theia/ai-core';
|
||||
import {
|
||||
Agent,
|
||||
isLanguageModelStreamResponse,
|
||||
isLanguageModelTextResponse,
|
||||
LanguageModelRegistry,
|
||||
LanguageModelStreamResponsePart
|
||||
} from '@theia/ai-core/lib/common';
|
||||
import { ContributionProvider, ILogger, isArray, nls } from '@theia/core';
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
import {
|
||||
ChatModel,
|
||||
ChatRequestModel,
|
||||
ChatResponseContent,
|
||||
ErrorChatResponseContentImpl,
|
||||
MarkdownChatResponseContentImpl,
|
||||
MutableChatRequestModel,
|
||||
ThinkingChatResponseContentImpl,
|
||||
ToolCallChatResponseContentImpl,
|
||||
ToolCallArgumentsDeltaContent,
|
||||
ErrorChatResponseContent,
|
||||
InformationalChatResponseContent,
|
||||
} from './chat-model';
|
||||
import { ChatToolRequestService } from './chat-tool-request-service';
|
||||
import { parseContents } from './parse-contents';
|
||||
import { DefaultResponseContentFactory, ResponseContentMatcher, ResponseContentMatcherProvider } from './response-content-matcher';
|
||||
import { ImageContextVariable } from './image-context-variable';
|
||||
|
||||
/**
|
||||
* System message content, enriched with function descriptions.
|
||||
*/
|
||||
export interface SystemMessageDescription {
|
||||
text: string;
|
||||
/** All functions references in the system message. */
|
||||
functionDescriptions?: Map<string, ToolRequest>;
|
||||
/** The prompt variant ID used */
|
||||
promptVariantId?: string;
|
||||
/** Whether the prompt variant is customized */
|
||||
isPromptVariantCustomized?: boolean;
|
||||
}
|
||||
export namespace SystemMessageDescription {
|
||||
export function fromResolvedPromptFragment(
|
||||
resolvedPrompt: ResolvedPromptFragment,
|
||||
promptVariantId?: string,
|
||||
isPromptVariantCustomized?: boolean
|
||||
): SystemMessageDescription {
|
||||
return {
|
||||
text: resolvedPrompt.text,
|
||||
functionDescriptions: resolvedPrompt.functionDescriptions,
|
||||
promptVariantId,
|
||||
isPromptVariantCustomized
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatSessionContext extends AIVariableContext {
|
||||
request?: ChatRequestModel;
|
||||
model: ChatModel;
|
||||
}
|
||||
|
||||
export namespace ChatSessionContext {
|
||||
export function is(candidate: unknown): candidate is ChatSessionContext {
|
||||
return typeof candidate === 'object' && !!candidate && 'model' in candidate;
|
||||
}
|
||||
|
||||
export function getVariables(context: ChatSessionContext): readonly AIVariableResolutionRequest[] {
|
||||
return context.request?.context.variables.map(AIVariableResolutionRequest.fromResolved) ?? context.model.context.getVariables();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The location from where an chat agent may be invoked.
|
||||
* Based on the location, a different context may be available.
|
||||
*/
|
||||
export enum ChatAgentLocation {
|
||||
Panel = 'panel',
|
||||
Terminal = 'terminal',
|
||||
Notebook = 'notebook',
|
||||
Editor = 'editor'
|
||||
}
|
||||
|
||||
export namespace ChatAgentLocation {
|
||||
export const ALL: ChatAgentLocation[] = [ChatAgentLocation.Panel, ChatAgentLocation.Terminal, ChatAgentLocation.Notebook, ChatAgentLocation.Editor];
|
||||
|
||||
export function fromRaw(value: string): ChatAgentLocation {
|
||||
switch (value) {
|
||||
case 'panel': return ChatAgentLocation.Panel;
|
||||
case 'terminal': return ChatAgentLocation.Terminal;
|
||||
case 'notebook': return ChatAgentLocation.Notebook;
|
||||
case 'editor': return ChatAgentLocation.Editor;
|
||||
}
|
||||
return ChatAgentLocation.Panel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a mode that a chat agent can operate in.
|
||||
*/
|
||||
export interface ChatMode {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly isDefault?: boolean;
|
||||
}
|
||||
|
||||
export const ChatAgent = Symbol('ChatAgent');
|
||||
/**
|
||||
* A chat agent is a specialized agent with a common interface for its invocation.
|
||||
*/
|
||||
export interface ChatAgent extends Agent {
|
||||
locations: ChatAgentLocation[];
|
||||
iconClass?: string;
|
||||
modes?: ChatMode[];
|
||||
invoke(request: MutableChatRequestModel, chatAgentService?: ChatAgentService): Promise<void>;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export abstract class AbstractChatAgent implements ChatAgent {
|
||||
@inject(LanguageModelRegistry) protected languageModelRegistry: LanguageModelRegistry;
|
||||
@inject(ILogger) protected logger: ILogger;
|
||||
@inject(ChatToolRequestService) protected chatToolRequestService: ChatToolRequestService;
|
||||
@inject(LanguageModelService) protected languageModelService: LanguageModelService;
|
||||
@inject(PromptService) protected promptService: PromptService;
|
||||
|
||||
@inject(ContributionProvider) @named(ResponseContentMatcherProvider)
|
||||
protected contentMatcherProviders: ContributionProvider<ResponseContentMatcherProvider>;
|
||||
|
||||
@inject(DefaultResponseContentFactory)
|
||||
protected defaultContentFactory: DefaultResponseContentFactory;
|
||||
|
||||
readonly abstract id: string;
|
||||
readonly abstract name: string;
|
||||
readonly abstract languageModelRequirements: LanguageModelRequirement[];
|
||||
iconClass: string = 'codicon codicon-copilot';
|
||||
locations: ChatAgentLocation[] = ChatAgentLocation.ALL;
|
||||
tags: string[] = [nls.localizeByDefault('Chat')];
|
||||
description: string = '';
|
||||
variables: string[] = [];
|
||||
prompts: PromptVariantSet[] = [];
|
||||
agentSpecificVariables: AgentSpecificVariables[] = [];
|
||||
functions: string[] = [];
|
||||
protected readonly abstract defaultLanguageModelPurpose: string;
|
||||
protected systemPromptId: string | undefined = undefined;
|
||||
protected additionalToolRequests: ToolRequest[] = [];
|
||||
protected contentMatchers: ResponseContentMatcher[] = [];
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.initializeContentMatchers();
|
||||
}
|
||||
|
||||
protected initializeContentMatchers(): void {
|
||||
const contributedContentMatchers = this.contentMatcherProviders.getContributions().flatMap(provider => provider.matchers);
|
||||
this.contentMatchers.push(...contributedContentMatchers);
|
||||
}
|
||||
|
||||
async invoke(request: MutableChatRequestModel): Promise<void> {
|
||||
try {
|
||||
const languageModel = await this.getLanguageModel(this.defaultLanguageModelPurpose);
|
||||
if (!languageModel) {
|
||||
throw new Error(nls.localize('theia/ai/chat/couldNotFindMatchingLM', 'Couldn\'t find a matching language model. Please check your setup!'));
|
||||
}
|
||||
const systemMessageDescription = await this.getSystemMessageDescription({ model: request.session, request } satisfies ChatSessionContext);
|
||||
|
||||
if (systemMessageDescription?.promptVariantId) {
|
||||
request.response.setPromptVariantInfo(
|
||||
systemMessageDescription.promptVariantId,
|
||||
systemMessageDescription.isPromptVariantCustomized ?? false
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await this.getMessages(request.session);
|
||||
|
||||
if (systemMessageDescription) {
|
||||
const systemMsg: LanguageModelMessage = {
|
||||
actor: 'system',
|
||||
type: 'text',
|
||||
text: systemMessageDescription.text
|
||||
};
|
||||
// insert system message at the beginning of the request messages
|
||||
messages.unshift(systemMsg);
|
||||
}
|
||||
|
||||
const systemMessageToolRequests = systemMessageDescription?.functionDescriptions?.values();
|
||||
const tools = [
|
||||
...this.chatToolRequestService.getChatToolRequests(request),
|
||||
...this.chatToolRequestService.toChatToolRequests(systemMessageToolRequests ? Array.from(systemMessageToolRequests) : [], request),
|
||||
...this.chatToolRequestService.toChatToolRequests(this.additionalToolRequests, request)
|
||||
];
|
||||
const languageModelResponse = await this.sendLlmRequest(
|
||||
request,
|
||||
messages,
|
||||
tools,
|
||||
languageModel,
|
||||
systemMessageDescription?.promptVariantId,
|
||||
systemMessageDescription?.isPromptVariantCustomized
|
||||
);
|
||||
|
||||
await this.addContentsToResponse(languageModelResponse, request);
|
||||
await this.onResponseComplete(request);
|
||||
|
||||
} catch (e) {
|
||||
this.handleError(request, e);
|
||||
}
|
||||
}
|
||||
|
||||
protected parseContents(text: string, request: MutableChatRequestModel): ChatResponseContent[] {
|
||||
return parseContents(
|
||||
text,
|
||||
request,
|
||||
this.contentMatchers,
|
||||
this.defaultContentFactory?.create.bind(this.defaultContentFactory)
|
||||
);
|
||||
};
|
||||
|
||||
protected handleError(request: MutableChatRequestModel, error: Error): void {
|
||||
console.error('Error handling chat interaction:', error);
|
||||
request.response.response.addContent(new ErrorChatResponseContentImpl(error));
|
||||
request.response.error(error);
|
||||
}
|
||||
|
||||
protected getLanguageModelSelector(languageModelPurpose: string): LanguageModelRequirement {
|
||||
return this.languageModelRequirements.find(req => req.purpose === languageModelPurpose)!;
|
||||
}
|
||||
|
||||
protected async getLanguageModel(languageModelPurpose: string): Promise<LanguageModel> {
|
||||
return this.selectLanguageModel(this.getLanguageModelSelector(languageModelPurpose));
|
||||
}
|
||||
|
||||
protected async selectLanguageModel(selector: LanguageModelRequirement): Promise<LanguageModel> {
|
||||
const languageModel = await this.languageModelRegistry.selectLanguageModel({ agent: this.id, ...selector });
|
||||
if (!languageModel) {
|
||||
throw new Error(nls.localize('theia/ai/chat/couldNotFindReadyLMforAgent', 'Couldn\'t find a ready language model for agent {0}. Please check your setup!', this.id));
|
||||
}
|
||||
return languageModel;
|
||||
}
|
||||
|
||||
protected async getSystemMessageDescription(context: AIVariableContext): Promise<SystemMessageDescription | undefined> {
|
||||
if (this.systemPromptId === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variantInfo = this.promptService.getPromptVariantInfo(this.systemPromptId);
|
||||
|
||||
const resolvedPrompt = await this.promptService.getResolvedPromptFragment(this.systemPromptId, undefined, context);
|
||||
return resolvedPrompt ? SystemMessageDescription.fromResolvedPromptFragment(resolvedPrompt, variantInfo?.variantId, variantInfo?.isCustomized) : undefined;
|
||||
}
|
||||
|
||||
protected async getMessages(
|
||||
model: ChatModel, includeResponseInProgress = false
|
||||
): Promise<LanguageModelMessage[]> {
|
||||
const requestMessages = model.getRequests().flatMap(request => {
|
||||
const messages: LanguageModelMessage[] = [];
|
||||
const text = request.message.parts.map(part => part.promptText).join('');
|
||||
if (text.length > 0) {
|
||||
messages.push({
|
||||
actor: 'user',
|
||||
type: 'text',
|
||||
text: text,
|
||||
});
|
||||
}
|
||||
const imageMessages = request.context.variables
|
||||
.filter(variable => ImageContextVariable.isResolvedImageContext(variable))
|
||||
.map(variable => ImageContextVariable.parseResolved(variable))
|
||||
.filter(content => content !== undefined)
|
||||
.map(content => ({
|
||||
actor: 'user' as const,
|
||||
type: 'image' as const,
|
||||
image: {
|
||||
base64data: content!.data,
|
||||
mimeType: content!.mimeType
|
||||
}
|
||||
}));
|
||||
messages.push(...imageMessages);
|
||||
|
||||
if (request.response.isComplete || includeResponseInProgress) {
|
||||
const responseMessages: LanguageModelMessage[] = request.response.response.content
|
||||
.filter(c => {
|
||||
// we do not send errors or informational content
|
||||
if (ErrorChatResponseContent.is(c) || InformationalChatResponseContent.is(c)) {
|
||||
return false;
|
||||
}
|
||||
// content even has an own converter, definitely include it
|
||||
if (ChatResponseContent.hasToLanguageModelMessage(c)) {
|
||||
return true;
|
||||
}
|
||||
// make sure content did not indicate to be excluded by returning undefined in asString
|
||||
if (ChatResponseContent.hasAsString(c) && c.asString() === undefined) {
|
||||
return false;
|
||||
}
|
||||
// include the rest
|
||||
return true;
|
||||
})
|
||||
.flatMap(c => {
|
||||
if (ChatResponseContent.hasToLanguageModelMessage(c)) {
|
||||
return c.toLanguageModelMessage();
|
||||
}
|
||||
|
||||
return {
|
||||
actor: 'ai',
|
||||
type: 'text',
|
||||
text: c.asString?.() || c.asDisplayString?.() || '',
|
||||
} satisfies TextMessage;
|
||||
});
|
||||
messages.push(...responseMessages);
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
|
||||
return requestMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate tools by name (falling back to id) while preserving the first occurrence and order.
|
||||
*/
|
||||
protected deduplicateTools(toolRequests: ToolRequest[]): ToolRequest[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ToolRequest[] = [];
|
||||
for (const tool of toolRequests) {
|
||||
const key = tool.name ?? tool.id;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
deduped.push(tool);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
protected async sendLlmRequest(
|
||||
request: MutableChatRequestModel,
|
||||
messages: LanguageModelMessage[],
|
||||
toolRequests: ToolRequest[],
|
||||
languageModel: LanguageModel,
|
||||
promptVariantId?: string,
|
||||
isPromptVariantCustomized?: boolean
|
||||
): Promise<LanguageModelResponse> {
|
||||
const agentSettings = this.getLlmSettings();
|
||||
const settings = { ...agentSettings, ...request.session.settings };
|
||||
const dedupedTools = this.deduplicateTools(toolRequests);
|
||||
const tools = dedupedTools.length > 0 ? dedupedTools : undefined;
|
||||
return this.languageModelService.sendRequest(
|
||||
languageModel,
|
||||
{
|
||||
messages,
|
||||
tools,
|
||||
settings,
|
||||
agentId: this.id,
|
||||
sessionId: request.session.id,
|
||||
requestId: request.id,
|
||||
cancellationToken: request.response.cancellationToken,
|
||||
promptVariantId,
|
||||
isPromptVariantCustomized
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns the settings, such as `temperature`, to be used in all language model requests. Returns `undefined` by default.
|
||||
*/
|
||||
protected getLlmSettings(): { [key: string]: unknown; } | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoked after the response by the LLM completed successfully.
|
||||
*
|
||||
* The default implementation sets the state of the response to `complete`.
|
||||
* Subclasses may override this method to perform additional actions or keep the response open for processing further requests.
|
||||
*/
|
||||
protected async onResponseComplete(request: MutableChatRequestModel): Promise<void> {
|
||||
return request.response.complete();
|
||||
}
|
||||
|
||||
protected abstract addContentsToResponse(languageModelResponse: LanguageModelResponse, request: MutableChatRequestModel): Promise<void>;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export abstract class AbstractTextToModelParsingChatAgent<T> extends AbstractChatAgent {
|
||||
|
||||
protected async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: MutableChatRequestModel): Promise<void> {
|
||||
const responseAsText = await getTextOfResponse(languageModelResponse);
|
||||
const parsedCommand = await this.parseTextResponse(responseAsText);
|
||||
const content = this.createResponseContent(parsedCommand, request);
|
||||
request.response.response.addContent(content);
|
||||
}
|
||||
|
||||
protected abstract parseTextResponse(text: string): Promise<T>;
|
||||
|
||||
protected abstract createResponseContent(parsedModel: T, request: MutableChatRequestModel): ChatResponseContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating ToolCallChatResponseContent instances.
|
||||
*/
|
||||
@injectable()
|
||||
export class ToolCallChatResponseContentFactory {
|
||||
create(toolCall: ToolCall): ChatResponseContent {
|
||||
// Return delta content for streaming argument updates
|
||||
if (toolCall.argumentsDelta && toolCall.id && toolCall.function?.arguments) {
|
||||
const deltaContent: ToolCallArgumentsDeltaContent = {
|
||||
kind: 'toolCallArgumentsDelta',
|
||||
id: toolCall.id,
|
||||
delta: toolCall.function.arguments
|
||||
};
|
||||
return deltaContent;
|
||||
}
|
||||
|
||||
// Return full tool call content
|
||||
return new ToolCallChatResponseContentImpl(
|
||||
toolCall.id,
|
||||
toolCall.function?.name,
|
||||
toolCall.function?.arguments,
|
||||
toolCall.finished,
|
||||
toolCall.result,
|
||||
toolCall.data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent {
|
||||
@inject(ToolCallChatResponseContentFactory)
|
||||
protected toolCallResponseContentFactory: ToolCallChatResponseContentFactory;
|
||||
|
||||
protected override async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: MutableChatRequestModel): Promise<void> {
|
||||
if (isLanguageModelTextResponse(languageModelResponse)) {
|
||||
const contents = this.parseContents(languageModelResponse.text, request);
|
||||
request.response.response.addContents(contents);
|
||||
return;
|
||||
}
|
||||
if (isLanguageModelStreamResponse(languageModelResponse)) {
|
||||
await this.addStreamResponse(languageModelResponse, request);
|
||||
return;
|
||||
}
|
||||
this.logger.error(
|
||||
'Received unknown response in agent. Return response as text'
|
||||
);
|
||||
request.response.response.addContent(
|
||||
new MarkdownChatResponseContentImpl(
|
||||
JSON.stringify(languageModelResponse)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected async addStreamResponse(languageModelResponse: LanguageModelStreamResponse, request: MutableChatRequestModel): Promise<void> {
|
||||
let completeTextBuffer = '';
|
||||
let startIndex = request.response.response.content.length;
|
||||
for await (const token of languageModelResponse.stream) {
|
||||
// Skip unknown tokens. For example OpenAI sends empty tokens around tool calls
|
||||
if (!isLanguageModelStreamResponsePart(token)) {
|
||||
console.debug(`Unknown token: '${JSON.stringify(token)}'. Skipping`);
|
||||
continue;
|
||||
}
|
||||
const newContent = this.parse(token, request);
|
||||
if (!isTextResponsePart(token)) {
|
||||
// For non-text tokens (like tool calls), add them directly
|
||||
if (isArray(newContent)) {
|
||||
request.response.response.addContents(newContent);
|
||||
} else {
|
||||
request.response.response.addContent(newContent);
|
||||
}
|
||||
// And reset the marker index and the text buffer as we skip matching across non-text tokens
|
||||
startIndex = request.response.response.content.length;
|
||||
completeTextBuffer = '';
|
||||
} else {
|
||||
// parse the entire text so far (since beginning of the stream or last non-text token)
|
||||
// and replace the entire content with the currently parsed content parts
|
||||
completeTextBuffer += token.content;
|
||||
|
||||
const parsedContents = this.parseContents(completeTextBuffer, request);
|
||||
const contentBeforeMarker = startIndex > 0
|
||||
? request.response.response.content.slice(0, startIndex)
|
||||
: [];
|
||||
|
||||
request.response.response.clearContent();
|
||||
request.response.response.addContents(contentBeforeMarker);
|
||||
request.response.response.addContents(parsedContents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected parse(token: LanguageModelStreamResponsePart, request: MutableChatRequestModel): ChatResponseContent | ChatResponseContent[] {
|
||||
if (isTextResponsePart(token)) {
|
||||
const content = token.content;
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
if (content !== undefined && content !== null) {
|
||||
return this.defaultContentFactory.create(content, request);
|
||||
}
|
||||
}
|
||||
if (isToolCallResponsePart(token)) {
|
||||
const toolCalls = token.tool_calls;
|
||||
if (toolCalls !== undefined) {
|
||||
const toolCallContents = toolCalls.map(toolCall =>
|
||||
this.createToolCallResponseContent(toolCall)
|
||||
);
|
||||
return toolCallContents;
|
||||
}
|
||||
}
|
||||
if (isThinkingResponsePart(token)) {
|
||||
return new ThinkingChatResponseContentImpl(token.thought, token.signature);
|
||||
}
|
||||
if (isUsageResponsePart(token)) {
|
||||
return [];
|
||||
}
|
||||
return this.defaultContentFactory.create('', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ToolCallChatResponseContent instance from the provided tool call data.
|
||||
*
|
||||
* This method is called when parsing stream response tokens that contain tool call data.
|
||||
* Subclasses can override this method to customize the creation of tool call response contents.
|
||||
*
|
||||
* @param toolCall The ToolCall.
|
||||
* @returns A ChatResponseContent representing the tool call.
|
||||
*/
|
||||
protected createToolCallResponseContent(toolCall: ToolCall): ChatResponseContent {
|
||||
return this.toolCallResponseContentFactory.create(toolCall);
|
||||
}
|
||||
}
|
||||
391
packages/ai-chat/src/common/chat-auto-save.spec.ts
Normal file
391
packages/ai-chat/src/common/chat-auto-save.spec.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { Container } from '@theia/core/shared/inversify';
|
||||
import { ChatServiceImpl, DefaultChatAgentId } from './chat-service';
|
||||
import { ChatSessionStore, ChatSessionIndex, ChatModelWithMetadata } from './chat-session-store';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
import { ChatRequestParser } from './chat-request-parser';
|
||||
import { AIVariableService, ToolInvocationRegistry } from '@theia/ai-core';
|
||||
import { ILogger } from '@theia/core';
|
||||
import { ChatAgentLocation } from './chat-agents';
|
||||
import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer';
|
||||
import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer';
|
||||
import { ChatModel } from './chat-model';
|
||||
import { SerializedChatData } from './chat-model-serialization';
|
||||
import { ParsedChatRequest, ParsedChatRequestTextPart } from './parsed-chat-request';
|
||||
|
||||
describe('Chat Auto-Save Mechanism', () => {
|
||||
let chatService: ChatServiceImpl;
|
||||
let sessionStore: MockChatSessionStore;
|
||||
let container: Container;
|
||||
|
||||
class MockChatSessionStore implements ChatSessionStore {
|
||||
public saveCount = 0;
|
||||
public savedSessions: Array<ChatModel | ChatModelWithMetadata> = [];
|
||||
public lastSaveTimes: Map<string, number> = new Map();
|
||||
|
||||
async storeSessions(...sessions: Array<ChatModel | ChatModelWithMetadata>): Promise<void> {
|
||||
this.saveCount++;
|
||||
this.savedSessions = sessions;
|
||||
// Track save times per session
|
||||
sessions.forEach(session => {
|
||||
const id = 'model' in session ? session.model.id : session.id;
|
||||
this.lastSaveTimes.set(id, Date.now());
|
||||
});
|
||||
}
|
||||
|
||||
async readSession(sessionId: string): Promise<SerializedChatData | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async clearAllSessions(): Promise<void> {
|
||||
this.savedSessions = [];
|
||||
this.saveCount = 0;
|
||||
this.lastSaveTimes.clear();
|
||||
}
|
||||
|
||||
async getSessionIndex(): Promise<ChatSessionIndex> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async hasPersistedSessions(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.saveCount = 0;
|
||||
this.savedSessions = [];
|
||||
this.lastSaveTimes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class MockChatAgentService {
|
||||
private testAgent = {
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
invoke: () => Promise.resolve()
|
||||
};
|
||||
|
||||
getAgent(id: string): typeof this.testAgent | undefined {
|
||||
return id === 'test-agent' ? this.testAgent : undefined;
|
||||
}
|
||||
getAgents(): typeof this.testAgent[] {
|
||||
return [this.testAgent];
|
||||
}
|
||||
}
|
||||
|
||||
class MockChatRequestParser {
|
||||
parseChatRequest(): Promise<ParsedChatRequest> {
|
||||
return Promise.resolve({
|
||||
request: { text: 'test' },
|
||||
parts: [
|
||||
new ParsedChatRequestTextPart(
|
||||
{ start: 0, endExclusive: 4 },
|
||||
'test'
|
||||
)
|
||||
],
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MockAIVariableService {
|
||||
resolveVariables(): Promise<unknown[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
resolveVariable(): Promise<undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
class MockLogger {
|
||||
error(): void { }
|
||||
warn(): void { }
|
||||
info(): void { }
|
||||
debug(): void { }
|
||||
}
|
||||
|
||||
const mockToolInvocationRegistry: ToolInvocationRegistry = {
|
||||
registerTool: () => { },
|
||||
getFunction: () => undefined,
|
||||
getFunctions: () => [],
|
||||
getAllFunctions: () => [],
|
||||
unregisterAllTools: () => { },
|
||||
onDidChange: () => ({ dispose: () => { } })
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
sessionStore = new MockChatSessionStore();
|
||||
|
||||
container.bind(ChatSessionStore).toConstantValue(sessionStore);
|
||||
container.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
||||
container.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
container.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
container.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
container.bind(DefaultChatAgentId).toConstantValue({ id: 'test-agent' });
|
||||
container.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
||||
|
||||
// Bind deserializer registries
|
||||
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
||||
container.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
||||
container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
|
||||
container.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
chatService = container.get(ChatServiceImpl);
|
||||
});
|
||||
|
||||
describe('Auto-save on response completion', () => {
|
||||
it('should auto-save when response is complete', async () => {
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
const initialSaveCount = sessionStore.saveCount;
|
||||
|
||||
// Send a request
|
||||
const invocation = await chatService.sendRequest(session.id, { text: 'Test request' });
|
||||
const responseModel = await invocation!.responseCreated;
|
||||
|
||||
// Complete the response
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(responseModel as any).complete();
|
||||
|
||||
// Wait for auto-save to complete (debounce is 500ms + execution time)
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Verify session was auto-saved
|
||||
expect(sessionStore.saveCount).to.be.greaterThan(initialSaveCount);
|
||||
});
|
||||
|
||||
it('should auto-save when response has error', async () => {
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
const initialSaveCount = sessionStore.saveCount;
|
||||
|
||||
// Send a request that will error
|
||||
const invocation = await chatService.sendRequest(session.id, { text: 'Test request' });
|
||||
const responseModel = await invocation!.responseCreated;
|
||||
|
||||
// Simulate error
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(responseModel as any).error(new Error('Test error'));
|
||||
|
||||
// Wait for auto-save to complete (debounce is 500ms + execution time)
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Verify session was auto-saved even on error
|
||||
expect(sessionStore.saveCount).to.be.greaterThan(initialSaveCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-save on changeset updates', () => {
|
||||
it('should auto-save when changeset elements are updated', async () => {
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
// Add a request so the session is not empty
|
||||
await chatService.sendRequest(session.id, { text: 'Test request' });
|
||||
sessionStore.reset();
|
||||
|
||||
// Trigger changeset update event via model's internal emitter
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(session.model as any)._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: [] });
|
||||
|
||||
// Wait for auto-save to complete (debounce is 500ms + execution time)
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Verify session was auto-saved
|
||||
expect(sessionStore.saveCount).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it('should not auto-save on non-changeset events', async () => {
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
sessionStore.reset();
|
||||
|
||||
// Trigger other kind of event (like 'addRequest')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(session.model as any)._onDidChangeEmitter.fire({ kind: 'addRequest' });
|
||||
|
||||
// Wait to ensure no save happens
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Verify no auto-save occurred
|
||||
expect(sessionStore.saveCount).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-save for all sessions', () => {
|
||||
it('should save all non-empty sessions', async () => {
|
||||
const session1 = chatService.createSession(ChatAgentLocation.Panel);
|
||||
const session2 = chatService.createSession(ChatAgentLocation.Panel);
|
||||
|
||||
sessionStore.reset();
|
||||
|
||||
// Send requests to both sessions
|
||||
const invocation1 = await chatService.sendRequest(session1.id, { text: 'Request 1' });
|
||||
const invocation2 = await chatService.sendRequest(session2.id, { text: 'Request 2' });
|
||||
|
||||
// Complete both responses
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(await invocation1!.responseCreated as any).complete();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(await invocation2!.responseCreated as any).complete();
|
||||
|
||||
// Wait for auto-save to complete (debounce is 500ms + execution time)
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Verify both sessions were saved (check lastSaveTimes since sessions are saved individually)
|
||||
expect(sessionStore.saveCount).to.be.greaterThan(0);
|
||||
expect(sessionStore.lastSaveTimes.has(session1.id)).to.be.true;
|
||||
expect(sessionStore.lastSaveTimes.has(session2.id)).to.be.true;
|
||||
});
|
||||
|
||||
it('should not save empty sessions', async () => {
|
||||
// Create session without any requests
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
sessionStore.reset();
|
||||
|
||||
// Manually trigger save
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (chatService as any).saveSession(session.id);
|
||||
|
||||
// Verify empty session was not saved
|
||||
const savedSessionIds = sessionStore.savedSessions.map(s => 'model' in s ? s.model.id : s.id);
|
||||
expect(savedSessionIds).to.not.include(session.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-save without session store', () => {
|
||||
it('should handle auto-save gracefully when session store unavailable', async () => {
|
||||
// Create service without session store
|
||||
const containerWithoutStore = new Container();
|
||||
containerWithoutStore.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
||||
containerWithoutStore.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
containerWithoutStore.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
containerWithoutStore.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
containerWithoutStore.bind(DefaultChatAgentId).toConstantValue({ id: 'test-agent' });
|
||||
containerWithoutStore.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
||||
|
||||
// Bind deserializer registries
|
||||
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
||||
containerWithoutStore.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
||||
containerWithoutStore.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
|
||||
containerWithoutStore.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
const serviceWithoutStore = containerWithoutStore.get(ChatServiceImpl);
|
||||
|
||||
// Create session and send request
|
||||
const session = serviceWithoutStore.createSession(ChatAgentLocation.Panel);
|
||||
const invocation = await serviceWithoutStore.sendRequest(session.id, { text: 'Test' });
|
||||
|
||||
// Complete response - should not throw even without session store
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(await invocation!.responseCreated as any).complete();
|
||||
|
||||
// Wait to ensure no errors
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// No assertion needed - we're just verifying no exception is thrown
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-save setup for restored sessions', () => {
|
||||
it('should set up auto-save for restored sessions', async () => {
|
||||
// Create and save a session
|
||||
const session1 = chatService.createSession(ChatAgentLocation.Panel);
|
||||
await chatService.sendRequest(session1.id, { text: 'Test' });
|
||||
|
||||
const serialized = session1.model.toSerializable();
|
||||
|
||||
// Create new service instance
|
||||
const newContainer = new Container();
|
||||
const newSessionStore = new MockChatSessionStore();
|
||||
|
||||
newContainer.bind(ChatSessionStore).toConstantValue(newSessionStore);
|
||||
newContainer.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
||||
newContainer.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
newContainer.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
newContainer.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
newContainer.bind(DefaultChatAgentId).toConstantValue({ id: 'test-agent' });
|
||||
newContainer.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
||||
|
||||
// Bind deserializer registries
|
||||
const newContentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(newContentRegistry);
|
||||
newContainer.bind(ChatContentDeserializerRegistry).toConstantValue(newContentRegistry);
|
||||
newContainer.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
|
||||
newContainer.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
const newChatService = newContainer.get(ChatServiceImpl);
|
||||
|
||||
// Mock readSession to return the serialized data
|
||||
newSessionStore.readSession = async (id: string) => ({
|
||||
version: 1,
|
||||
model: serialized,
|
||||
pinnedAgentId: undefined,
|
||||
saveDate: Date.now()
|
||||
});
|
||||
|
||||
// Restore the session
|
||||
const restoredSession = await newChatService.getOrRestoreSession(serialized.sessionId);
|
||||
expect(restoredSession).to.not.be.undefined;
|
||||
|
||||
newSessionStore.reset();
|
||||
|
||||
// Trigger changeset update on restored session
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(restoredSession!.model as any)._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: [] });
|
||||
|
||||
// Wait for auto-save (debounce is 500ms + execution time)
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Verify auto-save was set up and triggered
|
||||
expect(newSessionStore.saveCount).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple auto-save triggers', () => {
|
||||
it('should handle multiple rapid auto-save triggers', async () => {
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
// Add a request so the session is not empty
|
||||
await chatService.sendRequest(session.id, { text: 'Test request' });
|
||||
sessionStore.reset();
|
||||
|
||||
// Trigger multiple changeset updates rapidly
|
||||
for (let i = 0; i < 5; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(session.model as any)._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: [] });
|
||||
}
|
||||
|
||||
// Wait for all saves to complete (debounce is 500ms + execution time)
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
|
||||
// Verify saves were triggered (implementation batches them)
|
||||
expect(sessionStore.saveCount).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
375
packages/ai-chat/src/common/chat-content-deserializer.spec.ts
Normal file
375
packages/ai-chat/src/common/chat-content-deserializer.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { URI } from '@theia/core';
|
||||
import { Position } from '@theia/core/shared/vscode-languageserver-protocol';
|
||||
import { ILogger } from '@theia/core/lib/common';
|
||||
import {
|
||||
ChatContentDeserializerRegistryImpl,
|
||||
DefaultChatContentDeserializerContribution
|
||||
} from './chat-content-deserializer';
|
||||
import {
|
||||
CodeChatResponseContentImpl,
|
||||
ErrorChatResponseContentImpl,
|
||||
HorizontalLayoutChatResponseContentImpl,
|
||||
InformationalChatResponseContentImpl,
|
||||
MarkdownChatResponseContentImpl,
|
||||
ProgressChatResponseContentImpl,
|
||||
QuestionResponseContentImpl,
|
||||
TextChatResponseContentImpl,
|
||||
ThinkingChatResponseContentImpl,
|
||||
ToolCallChatResponseContentImpl
|
||||
} from './chat-model';
|
||||
|
||||
class MockLogger {
|
||||
error(): void { }
|
||||
warn(): void { }
|
||||
info(): void { }
|
||||
debug(): void { }
|
||||
}
|
||||
|
||||
describe('Chat Content Serialization', () => {
|
||||
|
||||
let registry: ChatContentDeserializerRegistryImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new ChatContentDeserializerRegistryImpl();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(registry as any).logger = new MockLogger() as unknown as ILogger;
|
||||
const contribution = new DefaultChatContentDeserializerContribution();
|
||||
contribution.registerDeserializers(registry);
|
||||
});
|
||||
|
||||
describe('TextChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const original = new TextChatResponseContentImpl('Hello, World!');
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('text');
|
||||
expect(serialized!.data).to.deep.equal({ content: 'Hello, World!' });
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('text');
|
||||
expect(deserialized.asString?.()).to.equal('Hello, World!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThinkingChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const original = new ThinkingChatResponseContentImpl('Thinking...', 'sig123');
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('thinking');
|
||||
expect(serialized!.data).to.deep.equal({
|
||||
content: 'Thinking...',
|
||||
signature: 'sig123'
|
||||
});
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('thinking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const original = new MarkdownChatResponseContentImpl('# Title\n\nContent');
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('markdownContent');
|
||||
expect(serialized!.data).to.deep.equal({ content: '# Title\n\nContent' });
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('markdownContent');
|
||||
expect(deserialized.asString?.()).to.equal('# Title\n\nContent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InformationalChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const original = new InformationalChatResponseContentImpl('Info message');
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('informational');
|
||||
expect(serialized!.data).to.deep.equal({ content: 'Info message' });
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('informational');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize code without location', async () => {
|
||||
const original = new CodeChatResponseContentImpl('console.log("test")', 'typescript');
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('code');
|
||||
expect(serialized!.data).to.deep.equal({
|
||||
code: 'console.log("test")',
|
||||
language: 'typescript',
|
||||
location: undefined
|
||||
});
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('code');
|
||||
});
|
||||
|
||||
it('should serialize and deserialize code with location', async () => {
|
||||
const location = {
|
||||
uri: new URI('file:///test.ts'),
|
||||
position: Position.create(1, 0)
|
||||
};
|
||||
const original = new CodeChatResponseContentImpl('code', 'typescript', location);
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('code');
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCallChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const original = new ToolCallChatResponseContentImpl(
|
||||
'id123',
|
||||
'toolName',
|
||||
'{"arg": "value"}',
|
||||
true,
|
||||
'result'
|
||||
);
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('toolCall');
|
||||
expect(serialized!.data).to.deep.equal({
|
||||
id: 'id123',
|
||||
name: 'toolName',
|
||||
arguments: '{"arg": "value"}',
|
||||
finished: true,
|
||||
result: 'result'
|
||||
});
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('toolCall');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ErrorChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const error = new Error('Test error');
|
||||
const original = new ErrorChatResponseContentImpl(error);
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('error');
|
||||
expect(serialized!.data).to.have.property('message', 'Test error');
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProgressChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize correctly', async () => {
|
||||
const original = new ProgressChatResponseContentImpl('Processing...');
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('progress');
|
||||
expect(serialized!.data).to.deep.equal({ message: 'Processing...' });
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HorizontalLayoutChatResponseContentImpl', () => {
|
||||
it('should serialize and deserialize nested content', async () => {
|
||||
const child1 = new TextChatResponseContentImpl('Text 1');
|
||||
const child2 = new TextChatResponseContentImpl('Text 2');
|
||||
const original = new HorizontalLayoutChatResponseContentImpl([child1, child2]);
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('horizontal');
|
||||
expect(serialized!.data).to.have.property('content');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((serialized!.data as any).content).to.be.an('array').with.length(2);
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('horizontal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuestionResponseContentImpl', () => {
|
||||
it('should serialize and deserialize question with selected option', async () => {
|
||||
const options = [
|
||||
{ text: 'Blue' },
|
||||
{ text: 'Green' },
|
||||
{ text: 'Lavender' }
|
||||
];
|
||||
const original = new QuestionResponseContentImpl(
|
||||
'Which color do you find most calming?',
|
||||
options,
|
||||
undefined, // request
|
||||
undefined, // handler
|
||||
{ text: 'Blue' } // selectedOption
|
||||
);
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('question');
|
||||
expect(serialized!.data).to.deep.equal({
|
||||
question: 'Which color do you find most calming?',
|
||||
options: options,
|
||||
selectedOption: { text: 'Blue' }
|
||||
});
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('question');
|
||||
expect(deserialized.asString?.()).to.include('Question: Which color do you find most calming?');
|
||||
expect(deserialized.asString?.()).to.include('Answer: Blue');
|
||||
});
|
||||
|
||||
it('should serialize and deserialize question without selected option', async () => {
|
||||
const options = [
|
||||
{ text: 'Option 1' },
|
||||
{ text: 'Option 2' }
|
||||
];
|
||||
const original = new QuestionResponseContentImpl(
|
||||
'What is your choice?',
|
||||
options,
|
||||
undefined, // request
|
||||
undefined // handler
|
||||
// no selectedOption
|
||||
);
|
||||
const serialized = original.toSerializable?.();
|
||||
|
||||
expect(serialized).to.not.be.undefined;
|
||||
expect(serialized!.kind).to.equal('question');
|
||||
|
||||
// Simulate caller populating fallbackMessage
|
||||
const withFallback = {
|
||||
...serialized!,
|
||||
fallbackMessage: original.asString?.() || original.toString()
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(withFallback);
|
||||
expect(deserialized.kind).to.equal('question');
|
||||
expect(deserialized.asString?.()).to.include('Question: What is your choice?');
|
||||
expect(deserialized.asString?.()).to.include('No answer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatContentDeserializerRegistry', () => {
|
||||
it('should handle unknown content types with fallback', async () => {
|
||||
const unknownContent = {
|
||||
kind: 'unknown-type',
|
||||
fallbackMessage: 'Fallback text',
|
||||
data: { some: 'data' }
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(unknownContent);
|
||||
expect(deserialized.kind).to.equal('unknown');
|
||||
expect(deserialized.asString?.()).to.equal('Fallback text');
|
||||
});
|
||||
|
||||
it('should use fallback message when deserializer not found', async () => {
|
||||
const unknownContent = {
|
||||
kind: 'custom-extension-type',
|
||||
fallbackMessage: 'Custom content not available',
|
||||
data: undefined
|
||||
};
|
||||
|
||||
const deserialized = await registry.deserialize(unknownContent);
|
||||
expect(deserialized.asString?.()).to.equal('Custom content not available');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
328
packages/ai-chat/src/common/chat-content-deserializer.ts
Normal file
328
packages/ai-chat/src/common/chat-content-deserializer.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
ChatResponseContent,
|
||||
CodeChatResponseContentImpl,
|
||||
CommandChatResponseContentImpl,
|
||||
ErrorChatResponseContentImpl,
|
||||
HorizontalLayoutChatResponseContentImpl,
|
||||
InformationalChatResponseContentImpl,
|
||||
MarkdownChatResponseContentImpl,
|
||||
ProgressChatResponseContentImpl,
|
||||
QuestionResponseContentImpl,
|
||||
TextChatResponseContentImpl,
|
||||
ThinkingChatResponseContentImpl,
|
||||
ToolCallChatResponseContentImpl,
|
||||
UnknownChatResponseContentImpl,
|
||||
TextContentData,
|
||||
ThinkingContentData,
|
||||
MarkdownContentData,
|
||||
InformationalContentData,
|
||||
CodeContentData,
|
||||
ToolCallContentData,
|
||||
CommandContentData,
|
||||
HorizontalLayoutContentData,
|
||||
ProgressContentData,
|
||||
ErrorContentData,
|
||||
QuestionContentData
|
||||
} from './chat-model';
|
||||
import { SerializableChatResponseContentData } from './chat-model-serialization';
|
||||
import { ContributionProvider, ILogger, MaybePromise } from '@theia/core';
|
||||
|
||||
export const ChatContentDeserializer = Symbol('ChatContentDeserializer');
|
||||
|
||||
/**
|
||||
* A deserializer for a specific kind of chat response content.
|
||||
*
|
||||
* Deserializers are responsible for reconstructing `ChatResponseContent` instances
|
||||
* from their serialized data representations. Each deserializer handles a single
|
||||
* content type identified by its `kind` property.
|
||||
*
|
||||
* @template T The type of the data object that this deserializer can process.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const textDeserializer: ChatContentDeserializer<TextContentData> = {
|
||||
* kind: 'text',
|
||||
* deserialize: (data) => new TextChatResponseContentImpl(data.content)
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface ChatContentDeserializer<T = unknown> {
|
||||
/**
|
||||
* The unique identifier for the content type this deserializer handles.
|
||||
* This must match the `kind` property of the serialized content data.
|
||||
*/
|
||||
readonly kind: string;
|
||||
|
||||
/**
|
||||
* Deserializes the given data into a `ChatResponseContent` instance.
|
||||
*
|
||||
* @param data The serialized data to deserialize. The structure depends on the content kind.
|
||||
* @returns The deserialized content, or a Promise that resolves to the deserialized content.
|
||||
*/
|
||||
deserialize(data: T): MaybePromise<ChatResponseContent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribution point for registering chat content deserializers.
|
||||
*
|
||||
* Implement this interface to contribute custom deserializers for application-specific
|
||||
* or extension-specific chat response content types. Multiple contributions can be
|
||||
* registered, and all will be collected via the contribution provider pattern.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @injectable()
|
||||
* export class MyDeserializerContribution implements ChatContentDeserializerContribution {
|
||||
* registerDeserializers(registry: ChatContentDeserializerRegistry): void {
|
||||
* registry.register({
|
||||
* kind: 'customContent',
|
||||
* deserialize: (data: CustomContentData) =>
|
||||
* new CustomContentImpl(data.title, data.items)
|
||||
* });
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // In your module:
|
||||
* bind(ChatContentDeserializerContribution).to(MyDeserializerContribution).inSingletonScope();
|
||||
* ```
|
||||
*
|
||||
* @see {@link ChatContentDeserializerRegistry} for the registry that collects deserializers
|
||||
* @see {@link DefaultChatContentDeserializerContribution} for built-in content type deserializers
|
||||
*/
|
||||
export interface ChatContentDeserializerContribution {
|
||||
/**
|
||||
* Registers one or more deserializers with the provided registry.
|
||||
*
|
||||
* This method is called during the registry's initialization phase (at `@postConstruct()` time).
|
||||
*
|
||||
* @param registry The registry to register deserializers with
|
||||
*/
|
||||
registerDeserializers(registry: ChatContentDeserializerRegistry): void;
|
||||
}
|
||||
export const ChatContentDeserializerContribution = Symbol('ChatContentDeserializerContribution');
|
||||
|
||||
/**
|
||||
* Registry for chat content deserializers.
|
||||
*
|
||||
* This registry maintains a collection of deserializers for different content types
|
||||
* and provides methods to register new deserializers and deserialize content data.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Usage in a service:
|
||||
* @inject(ChatContentDeserializerRegistry)
|
||||
* protected deserializerRegistry: ChatContentDeserializerRegistry;
|
||||
*
|
||||
* async restoreContent(): Promise<void> {
|
||||
* const restoredContent = this.deserializerRegistry.deserialize(serializedData);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see {@link ChatContentDeserializerContribution} for how to contribute deserializers
|
||||
* @see {@link ChatContentDeserializerRegistryImpl} for the default implementation
|
||||
*/
|
||||
export interface ChatContentDeserializerRegistry {
|
||||
/**
|
||||
* Registers a deserializer for a specific content kind.
|
||||
*
|
||||
* If a deserializer for the same kind is already registered, it will be replaced.
|
||||
*
|
||||
* @param deserializer The deserializer to register
|
||||
*/
|
||||
register(deserializer: ChatContentDeserializer<unknown>): void;
|
||||
|
||||
/**
|
||||
* Deserializes the given serialized content data into a `ChatResponseContent` instance.
|
||||
*
|
||||
* The registry looks up the appropriate deserializer based on the `kind` property
|
||||
* of the serialized data and delegates to that deserializer's `deserialize` method.
|
||||
*
|
||||
* If no deserializer is found for the content kind, an `UnknownChatResponseContentImpl`
|
||||
* instance is returned with the original data and fallback message preserved.
|
||||
* A warning is also logged with the missing kind and available kinds.
|
||||
*
|
||||
* @param serialized The serialized content data to deserialize
|
||||
* @returns The deserialized content, or a Promise that resolves to the deserialized content
|
||||
*/
|
||||
deserialize(serialized: SerializableChatResponseContentData): MaybePromise<ChatResponseContent>;
|
||||
}
|
||||
export const ChatContentDeserializerRegistry = Symbol('ChatContentDeserializerRegistry');
|
||||
|
||||
/**
|
||||
* Default implementation of the chat content deserializer registry.
|
||||
*
|
||||
* This registry collects deserializers from all bound `ChatContentDeserializerContribution`
|
||||
* instances during its post-construction initialization phase. Deserializers are stored
|
||||
* in a map keyed by their content kind.
|
||||
*
|
||||
* The registry handles unknown content types gracefully by returning an
|
||||
* `UnknownChatResponseContentImpl` instance when no deserializer is found,
|
||||
* ensuring that chat sessions can still be loaded even if some content types
|
||||
* are no longer supported or available.
|
||||
*
|
||||
* @see {@link ChatContentDeserializerRegistry} for the interface definition
|
||||
*/
|
||||
@injectable()
|
||||
export class ChatContentDeserializerRegistryImpl implements ChatContentDeserializerRegistry {
|
||||
/**
|
||||
* Map of registered deserializers, keyed by content kind.
|
||||
*/
|
||||
protected deserializers = new Map<string, ChatContentDeserializer>();
|
||||
|
||||
@inject(ContributionProvider) @named(ChatContentDeserializerContribution)
|
||||
protected readonly deserializerContributions: ContributionProvider<ChatContentDeserializerContribution>;
|
||||
|
||||
@inject(ILogger) @named('ChatContentDeserializerRegistry')
|
||||
protected readonly logger: ILogger;
|
||||
|
||||
/**
|
||||
* Initializes the registry by collecting deserializers from all contributions.
|
||||
* This method is automatically called after construction due to the `@postConstruct` decorator.
|
||||
*/
|
||||
@postConstruct()
|
||||
protected initDeserializers(): void {
|
||||
for (const contribution of this.deserializerContributions.getContributions()) {
|
||||
contribution.registerDeserializers(this);
|
||||
}
|
||||
}
|
||||
|
||||
register(deserializer: ChatContentDeserializer): void {
|
||||
this.deserializers.set(deserializer.kind, deserializer);
|
||||
}
|
||||
|
||||
deserialize(serialized: SerializableChatResponseContentData): MaybePromise<ChatResponseContent> {
|
||||
const deserializer = this.deserializers.get(serialized.kind);
|
||||
if (!deserializer) {
|
||||
this.logger.warn('No deserializer found for kind:', serialized.kind, 'Available kinds:', Array.from(this.deserializers.keys()));
|
||||
return new UnknownChatResponseContentImpl(
|
||||
serialized.kind,
|
||||
serialized.fallbackMessage,
|
||||
serialized.data
|
||||
);
|
||||
}
|
||||
return deserializer.deserialize(serialized.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of the chat content deserializer contribution.
|
||||
*
|
||||
* This contribution registers deserializers for all built-in content types supported
|
||||
* by Theia AI.
|
||||
*
|
||||
* Note that some content types have limitations when deserialized from persistence.
|
||||
*
|
||||
* @see {@link ChatContentDeserializerContribution} for the contribution interface
|
||||
*/
|
||||
@injectable()
|
||||
export class DefaultChatContentDeserializerContribution implements ChatContentDeserializerContribution {
|
||||
registerDeserializers(registry: ChatContentDeserializerRegistry): void {
|
||||
registry.register({
|
||||
kind: 'text',
|
||||
deserialize: (data: TextContentData) => new TextChatResponseContentImpl(data.content)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'thinking',
|
||||
deserialize: (data: ThinkingContentData) => new ThinkingChatResponseContentImpl(
|
||||
data.content,
|
||||
data.signature
|
||||
)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'markdownContent',
|
||||
deserialize: (data: MarkdownContentData) => new MarkdownChatResponseContentImpl(data.content)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'informational',
|
||||
deserialize: (data: InformationalContentData) => new InformationalChatResponseContentImpl(data.content)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'code',
|
||||
deserialize: (data: CodeContentData) => new CodeChatResponseContentImpl(
|
||||
data.code,
|
||||
data.language,
|
||||
data.location
|
||||
)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'toolCall',
|
||||
deserialize: (data: ToolCallContentData) => new ToolCallChatResponseContentImpl(
|
||||
data.id,
|
||||
data.name,
|
||||
data.arguments,
|
||||
data.finished,
|
||||
data.result,
|
||||
data.data
|
||||
)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'command',
|
||||
deserialize: (data: CommandContentData) => {
|
||||
const command = data.commandId ? { id: data.commandId } : undefined;
|
||||
// Cannot restore customCallback since it contains a function
|
||||
return new CommandChatResponseContentImpl(command, undefined, data.arguments);
|
||||
}
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'horizontal',
|
||||
deserialize: async (data: HorizontalLayoutContentData) => {
|
||||
const childContentPromises = data.content.map(child => registry.deserialize(child));
|
||||
const childContent = Promise.all(childContentPromises);
|
||||
return new HorizontalLayoutChatResponseContentImpl(await childContent);
|
||||
}
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'progress',
|
||||
deserialize: (data: ProgressContentData) => new ProgressChatResponseContentImpl(data.message)
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'error',
|
||||
deserialize: (data: ErrorContentData) => {
|
||||
const error = new Error(data.message);
|
||||
if (data.stack) {
|
||||
error.stack = data.stack;
|
||||
}
|
||||
return new ErrorChatResponseContentImpl(error);
|
||||
}
|
||||
});
|
||||
|
||||
registry.register({
|
||||
kind: 'question',
|
||||
deserialize: (data: QuestionContentData) =>
|
||||
// Restore in read-only mode (no handler/request)
|
||||
new QuestionResponseContentImpl(
|
||||
data.question,
|
||||
data.options,
|
||||
undefined,
|
||||
undefined,
|
||||
data.selectedOption
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
670
packages/ai-chat/src/common/chat-model-serialization.spec.ts
Normal file
670
packages/ai-chat/src/common/chat-model-serialization.spec.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { ChatAgentLocation } from './chat-agents';
|
||||
import { MutableChatModel } from './chat-model';
|
||||
import { ParsedChatRequest, ParsedChatRequestTextPart, ParsedChatRequestVariablePart, ParsedChatRequestFunctionPart, ParsedChatRequestAgentPart } from './parsed-chat-request';
|
||||
import { ToolRequest } from '@theia/ai-core';
|
||||
import { SerializableTextPart, SerializableVariablePart, SerializableFunctionPart, SerializableAgentPart } from './chat-model-serialization';
|
||||
|
||||
describe('ChatModel Serialization and Restoration', () => {
|
||||
|
||||
function createParsedRequest(text: string): ParsedChatRequest {
|
||||
return {
|
||||
request: { text },
|
||||
parts: [
|
||||
new ParsedChatRequestTextPart(
|
||||
{ start: 0, endExclusive: text.length },
|
||||
text
|
||||
)
|
||||
],
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('Simple tree serialization', () => {
|
||||
it('should serialize a chat with a single request', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
model.addRequest(createParsedRequest('Hello'));
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
|
||||
expect(serialized.hierarchy).to.be.an('object');
|
||||
expect(serialized.hierarchy!.rootBranchId).to.be.a('string');
|
||||
expect(serialized.hierarchy!.branches).to.be.an('object');
|
||||
expect(serialized.requests).to.have.lengthOf(1);
|
||||
expect(serialized.requests[0].text).to.equal('Hello');
|
||||
});
|
||||
|
||||
it('should serialize a chat with multiple sequential requests', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
model.addRequest(createParsedRequest('First'));
|
||||
model.addRequest(createParsedRequest('Second'));
|
||||
model.addRequest(createParsedRequest('Third'));
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
|
||||
expect(serialized.hierarchy).to.be.an('object');
|
||||
expect(serialized.requests).to.have.lengthOf(3);
|
||||
|
||||
// Verify the hierarchy has 3 branches (one for each request)
|
||||
const branches = Object.values(serialized.hierarchy!.branches);
|
||||
expect(branches).to.have.lengthOf(3);
|
||||
|
||||
// Verify the active path through the tree
|
||||
const rootBranch = serialized.hierarchy!.branches[serialized.hierarchy!.rootBranchId];
|
||||
expect(rootBranch.items).to.have.lengthOf(1);
|
||||
expect(rootBranch.items[0].requestId).to.equal(serialized.requests[0].id);
|
||||
expect(rootBranch.items[0].nextBranchId).to.be.a('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tree serialization with alternatives (edited messages)', () => {
|
||||
it('should serialize a chat with edited messages', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
|
||||
// Add first request
|
||||
const req1 = model.addRequest(createParsedRequest('Original message'));
|
||||
req1.response.complete();
|
||||
|
||||
// Add second request
|
||||
model.addRequest(createParsedRequest('Follow-up'));
|
||||
|
||||
// Edit the first request (creating an alternative)
|
||||
const branch1 = model.getBranch(req1.id);
|
||||
expect(branch1).to.not.be.undefined;
|
||||
branch1!.add(model.addRequest(createParsedRequest('Edited message'), 'agent-1'));
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
|
||||
// Should have 3 requests: original, edited, and follow-up
|
||||
expect(serialized.requests).to.have.lengthOf(3);
|
||||
|
||||
// The root branch should have 2 items (original and edited alternatives)
|
||||
const rootBranch = serialized.hierarchy!.branches[serialized.hierarchy!.rootBranchId];
|
||||
expect(rootBranch.items).to.have.lengthOf(2);
|
||||
expect(rootBranch.items[0].requestId).to.equal(serialized.requests[0].id);
|
||||
expect(rootBranch.items[1].requestId).to.equal(serialized.requests[2].id);
|
||||
|
||||
// The active branch index should point to the most recent alternative
|
||||
expect(rootBranch.activeBranchIndex).to.be.at.least(0);
|
||||
});
|
||||
|
||||
it('should serialize nested alternatives (edited multiple times)', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
|
||||
// Add first request
|
||||
const req1 = model.addRequest(createParsedRequest('First'));
|
||||
req1.response.complete();
|
||||
|
||||
// Add second request
|
||||
const req2 = model.addRequest(createParsedRequest('Second'));
|
||||
req2.response.complete();
|
||||
|
||||
// Edit the second request (creating an alternative)
|
||||
const branch2 = model.getBranch(req2.id);
|
||||
expect(branch2).to.not.be.undefined;
|
||||
const req2edited = model.addRequest(createParsedRequest('Second (edited)'), 'agent-1');
|
||||
branch2!.add(req2edited);
|
||||
|
||||
// Add third request after the edited version
|
||||
model.addRequest(createParsedRequest('Third'));
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
|
||||
// Should have 4 requests total
|
||||
expect(serialized.requests).to.have.lengthOf(4);
|
||||
|
||||
// Find the second-level branch
|
||||
const rootBranch = serialized.hierarchy!.branches[serialized.hierarchy!.rootBranchId];
|
||||
const nextBranchId = rootBranch.items[rootBranch.activeBranchIndex].nextBranchId;
|
||||
expect(nextBranchId).to.be.a('string');
|
||||
|
||||
const secondBranch = serialized.hierarchy!.branches[nextBranchId!];
|
||||
expect(secondBranch.items).to.have.lengthOf(2); // Original and edited
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tree restoration from serialized data', () => {
|
||||
it('should restore a simple chat session', () => {
|
||||
// Create and serialize
|
||||
const model1 = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
model1.addRequest(createParsedRequest('Hello'));
|
||||
const serialized = model1.toSerializable();
|
||||
|
||||
// Restore
|
||||
const model2 = new MutableChatModel(serialized);
|
||||
|
||||
expect(model2.getRequests()).to.have.lengthOf(1);
|
||||
expect(model2.getRequests()[0].request.text).to.equal('Hello');
|
||||
});
|
||||
|
||||
it('should restore chat with multiple sequential requests', () => {
|
||||
// Create and serialize
|
||||
const model1 = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
model1.addRequest(createParsedRequest('First'));
|
||||
model1.addRequest(createParsedRequest('Second'));
|
||||
model1.addRequest(createParsedRequest('Third'));
|
||||
const serialized = model1.toSerializable();
|
||||
|
||||
// Restore
|
||||
const model2 = new MutableChatModel(serialized);
|
||||
|
||||
const requests = model2.getRequests();
|
||||
expect(requests).to.have.lengthOf(3);
|
||||
expect(requests[0].request.text).to.equal('First');
|
||||
expect(requests[1].request.text).to.equal('Second');
|
||||
expect(requests[2].request.text).to.equal('Third');
|
||||
});
|
||||
|
||||
it('should restore chat with edited messages (alternatives)', () => {
|
||||
// Create and serialize
|
||||
const model1 = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const req1 = model1.addRequest(createParsedRequest('Original'));
|
||||
req1.response.complete();
|
||||
|
||||
const branch1 = model1.getBranch(req1.id);
|
||||
const req1edited = model1.addRequest(createParsedRequest('Edited'), 'agent-1');
|
||||
branch1!.add(req1edited);
|
||||
|
||||
const serialized = model1.toSerializable();
|
||||
|
||||
// Verify serialization includes both alternatives
|
||||
expect(serialized.requests).to.have.lengthOf(2);
|
||||
|
||||
// Restore
|
||||
const model2 = new MutableChatModel(serialized);
|
||||
|
||||
// Check that both alternatives are restored
|
||||
const restoredBranch = model2.getBranch(serialized.requests[0].id);
|
||||
expect(restoredBranch).to.not.be.undefined;
|
||||
expect(restoredBranch!.items).to.have.lengthOf(2);
|
||||
expect(restoredBranch!.items[0].element.request.text).to.equal('Original');
|
||||
expect(restoredBranch!.items[1].element.request.text).to.equal('Edited');
|
||||
});
|
||||
|
||||
it('should restore the correct active branch indices', () => {
|
||||
// Create and serialize
|
||||
const model1 = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const req1 = model1.addRequest(createParsedRequest('Original'));
|
||||
req1.response.complete();
|
||||
|
||||
const branch1 = model1.getBranch(req1.id);
|
||||
const req1edited = model1.addRequest(createParsedRequest('Edited'), 'agent-1');
|
||||
branch1!.add(req1edited);
|
||||
|
||||
// Switch to the edited version
|
||||
branch1!.enable(req1edited);
|
||||
|
||||
const activeBranchIndex1 = branch1!.activeBranchIndex;
|
||||
const serialized = model1.toSerializable();
|
||||
|
||||
// Restore
|
||||
const model2 = new MutableChatModel(serialized);
|
||||
|
||||
const restoredBranch = model2.getBranch(serialized.requests[0].id);
|
||||
expect(restoredBranch).to.not.be.undefined;
|
||||
expect(restoredBranch!.activeBranchIndex).to.equal(activeBranchIndex1);
|
||||
});
|
||||
|
||||
it('should restore a simple session with hierarchy', () => {
|
||||
// Create serialized data with hierarchy
|
||||
const serializedData = {
|
||||
sessionId: 'simple-session',
|
||||
location: ChatAgentLocation.Panel,
|
||||
hierarchy: {
|
||||
rootBranchId: 'branch-root',
|
||||
branches: {
|
||||
'branch-root': {
|
||||
id: 'branch-root',
|
||||
items: [{ requestId: 'request-1' }],
|
||||
activeBranchIndex: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
requests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
text: 'Hello'
|
||||
}
|
||||
],
|
||||
responses: [
|
||||
{
|
||||
id: 'response-1',
|
||||
requestId: 'request-1',
|
||||
isComplete: true,
|
||||
isError: false,
|
||||
content: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Should restore without errors
|
||||
const model = new MutableChatModel(serializedData);
|
||||
expect(model.getRequests()).to.have.lengthOf(1);
|
||||
expect(model.getRequests()[0].request.text).to.equal('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete round-trip with complex tree', () => {
|
||||
it('should serialize and restore a complex tree structure', () => {
|
||||
// Create a complex chat with multiple edits
|
||||
const model1 = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
|
||||
// Level 1
|
||||
const req1 = model1.addRequest(createParsedRequest('Level 1 - Original'));
|
||||
req1.response.complete();
|
||||
|
||||
// Level 2
|
||||
const req2 = model1.addRequest(createParsedRequest('Level 2 - Original'));
|
||||
req2.response.complete();
|
||||
|
||||
// Edit Level 1
|
||||
const branch1 = model1.getBranch(req1.id);
|
||||
const req1edited = model1.addRequest(createParsedRequest('Level 1 - Edited'), 'agent-1');
|
||||
branch1!.add(req1edited);
|
||||
|
||||
// Add Level 2 alternative after edited Level 1
|
||||
const req2alt = model1.addRequest(createParsedRequest('Level 2 - Alternative'));
|
||||
req2alt.response.complete();
|
||||
|
||||
// Edit Level 2 alternative
|
||||
const branch2alt = model1.getBranch(req2alt.id);
|
||||
const req2altEdited = model1.addRequest(createParsedRequest('Level 2 - Alternative Edited'), 'agent-1');
|
||||
branch2alt!.add(req2altEdited);
|
||||
|
||||
const serialized = model1.toSerializable();
|
||||
|
||||
// Verify serialization
|
||||
expect(serialized.requests).to.have.lengthOf(5);
|
||||
expect(serialized.hierarchy).to.be.an('object');
|
||||
|
||||
// Restore
|
||||
const model2 = new MutableChatModel(serialized);
|
||||
|
||||
// Verify all requests are present
|
||||
const allRequests = model2.getAllRequests();
|
||||
expect(allRequests).to.have.lengthOf(5);
|
||||
|
||||
// Verify branch structure
|
||||
const restoredBranch1 = model2.getBranches()[0];
|
||||
expect(restoredBranch1.items).to.have.lengthOf(2); // Original + Edited
|
||||
|
||||
// Verify we can navigate the alternatives
|
||||
expect(restoredBranch1.items[0].element.request.text).to.equal('Level 1 - Original');
|
||||
expect(restoredBranch1.items[1].element.request.text).to.equal('Level 1 - Edited');
|
||||
});
|
||||
|
||||
it('should preserve all requests across multiple serialization/restoration cycles', () => {
|
||||
// Create initial model
|
||||
let model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const req1 = model.addRequest(createParsedRequest('Request 1'));
|
||||
req1.response.complete();
|
||||
|
||||
// Cycle 1
|
||||
let serialized = model.toSerializable();
|
||||
model = new MutableChatModel(serialized);
|
||||
|
||||
// Add more requests
|
||||
model.addRequest(createParsedRequest('Request 2'));
|
||||
|
||||
// Cycle 2
|
||||
serialized = model.toSerializable();
|
||||
model = new MutableChatModel(serialized);
|
||||
|
||||
// Add an edit
|
||||
const branch = model.getBranches()[0];
|
||||
const reqEdited = model.addRequest(createParsedRequest('Request 1 - Edited'), 'agent-1');
|
||||
branch.add(reqEdited);
|
||||
|
||||
// Final cycle
|
||||
serialized = model.toSerializable();
|
||||
const finalModel = new MutableChatModel(serialized);
|
||||
|
||||
// Verify all requests are preserved
|
||||
expect(finalModel.getBranches()[0].items).to.have.lengthOf(2);
|
||||
const allRequests = finalModel.getAllRequests();
|
||||
expect(allRequests).to.have.lengthOf(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ParsedChatRequest serialization', () => {
|
||||
it('should serialize and restore a simple text request', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
model.addRequest(createParsedRequest('Hello world'));
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
expect(serialized.requests[0].parsedRequest).to.not.be.undefined;
|
||||
expect(serialized.requests[0].parsedRequest!.parts).to.have.lengthOf(1);
|
||||
expect(serialized.requests[0].parsedRequest!.parts[0].kind).to.equal('text');
|
||||
const textPart = serialized.requests[0].parsedRequest!.parts[0] as SerializableTextPart;
|
||||
expect(textPart.text).to.equal('Hello world');
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredRequest = restored.getRequests()[0];
|
||||
expect(restoredRequest.message.parts).to.have.lengthOf(1);
|
||||
expect(restoredRequest.message.parts[0].kind).to.equal('text');
|
||||
expect(restoredRequest.message.parts[0].text).to.equal('Hello world');
|
||||
});
|
||||
|
||||
it('should serialize and restore a request with variable references', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: 'Use #file and #selection' },
|
||||
parts: [
|
||||
new ParsedChatRequestTextPart({ start: 0, endExclusive: 4 }, 'Use '),
|
||||
(() => {
|
||||
const variablePart = new ParsedChatRequestVariablePart(
|
||||
{ start: 4, endExclusive: 9 },
|
||||
'file',
|
||||
undefined
|
||||
);
|
||||
variablePart.resolution = {
|
||||
variable: { id: 'file-var', name: 'file', description: 'Current file' },
|
||||
value: 'file content here'
|
||||
};
|
||||
return variablePart;
|
||||
})(),
|
||||
new ParsedChatRequestTextPart({ start: 9, endExclusive: 14 }, ' and '),
|
||||
(() => {
|
||||
const variablePart = new ParsedChatRequestVariablePart(
|
||||
{ start: 14, endExclusive: 24 },
|
||||
'selection',
|
||||
undefined
|
||||
);
|
||||
variablePart.resolution = {
|
||||
variable: { id: 'sel-var', name: 'selection', description: 'Selected text' },
|
||||
value: 'selected text'
|
||||
};
|
||||
return variablePart;
|
||||
})()
|
||||
],
|
||||
toolRequests: new Map(),
|
||||
variables: [
|
||||
{
|
||||
variable: { id: 'file-var', name: 'file', description: 'Current file' },
|
||||
value: 'file content here'
|
||||
},
|
||||
{
|
||||
variable: { id: 'sel-var', name: 'selection', description: 'Selected text' },
|
||||
value: 'selected text'
|
||||
}
|
||||
]
|
||||
};
|
||||
model.addRequest(parsedRequest);
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
expect(serialized.requests[0].parsedRequest).to.not.be.undefined;
|
||||
expect(serialized.requests[0].parsedRequest!.parts).to.have.lengthOf(4);
|
||||
expect(serialized.requests[0].parsedRequest!.parts[1].kind).to.equal('var');
|
||||
const varPart1 = serialized.requests[0].parsedRequest!.parts[1] as SerializableVariablePart;
|
||||
expect(varPart1.variableId).to.equal('file-var');
|
||||
expect(varPart1.variableName).to.equal('file');
|
||||
expect(varPart1.variableValue).to.equal('file content here');
|
||||
expect(varPart1.variableDescription).to.equal('Current file');
|
||||
expect(serialized.requests[0].parsedRequest!.variables).to.have.lengthOf(2);
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredRequest = restored.getRequests()[0];
|
||||
expect(restoredRequest.message.parts).to.have.lengthOf(4);
|
||||
expect(restoredRequest.message.parts[1].kind).to.equal('var');
|
||||
const varPart = restoredRequest.message.parts[1] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.variableName).to.equal('file');
|
||||
expect(varPart.resolution?.variable.id).to.equal('file-var');
|
||||
expect(varPart.resolution?.value).to.equal('file content here');
|
||||
expect(restoredRequest.message.variables).to.have.lengthOf(2);
|
||||
expect(restoredRequest.message.variables[0].value).to.equal('file content here');
|
||||
expect(varPart.resolution?.variable.description).to.equal('Current file');
|
||||
});
|
||||
|
||||
it('should serialize and restore a request with agent references', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: '@codeAgent help me' },
|
||||
parts: [
|
||||
new ParsedChatRequestAgentPart(
|
||||
{ start: 0, endExclusive: 10 },
|
||||
'code-agent-id',
|
||||
'codeAgent'
|
||||
),
|
||||
new ParsedChatRequestTextPart({ start: 10, endExclusive: 19 }, ' help me')
|
||||
],
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
};
|
||||
model.addRequest(parsedRequest, 'code-agent-id');
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
expect(serialized.requests[0].parsedRequest).to.not.be.undefined;
|
||||
expect(serialized.requests[0].parsedRequest!.parts[0].kind).to.equal('agent');
|
||||
const agentPart1 = serialized.requests[0].parsedRequest!.parts[0] as SerializableAgentPart;
|
||||
expect(agentPart1.agentId).to.equal('code-agent-id');
|
||||
expect(agentPart1.agentName).to.equal('codeAgent');
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredRequest = restored.getRequests()[0];
|
||||
expect(restoredRequest.message.parts[0].kind).to.equal('agent');
|
||||
const agentPart = restoredRequest.message.parts[0] as ParsedChatRequestAgentPart;
|
||||
expect(agentPart.agentId).to.equal('code-agent-id');
|
||||
expect(agentPart.agentName).to.equal('codeAgent');
|
||||
});
|
||||
|
||||
it('should serialize and restore a request with tool requests', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const toolRequest: ToolRequest = {
|
||||
id: 'tool-1',
|
||||
name: 'search',
|
||||
description: 'Search the web',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' }
|
||||
},
|
||||
required: ['query']
|
||||
},
|
||||
handler: async () => 'search results'
|
||||
};
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: 'Search for ~tool-1' },
|
||||
parts: [
|
||||
new ParsedChatRequestTextPart({ start: 0, endExclusive: 11 }, 'Search for '),
|
||||
new ParsedChatRequestFunctionPart({ start: 11, endExclusive: 18 }, toolRequest)
|
||||
],
|
||||
toolRequests: new Map([['tool-1', toolRequest]]),
|
||||
variables: []
|
||||
};
|
||||
model.addRequest(parsedRequest);
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
expect(serialized.requests[0].parsedRequest).to.not.be.undefined;
|
||||
expect(serialized.requests[0].parsedRequest!.parts[1].kind).to.equal('function');
|
||||
const funcPart1 = serialized.requests[0].parsedRequest!.parts[1] as SerializableFunctionPart;
|
||||
expect(funcPart1.toolRequestId).to.equal('tool-1');
|
||||
expect(serialized.requests[0].parsedRequest!.toolRequests).to.have.lengthOf(1);
|
||||
expect(serialized.requests[0].parsedRequest!.toolRequests[0].id).to.equal('tool-1');
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredRequest = restored.getRequests()[0];
|
||||
expect(restoredRequest.message.parts[1].kind).to.equal('function');
|
||||
const funcPart = restoredRequest.message.parts[1] as ParsedChatRequestFunctionPart;
|
||||
expect(funcPart.toolRequest.id).to.equal('tool-1');
|
||||
expect(restoredRequest.message.toolRequests.size).to.equal(1);
|
||||
expect(restoredRequest.message.toolRequests.get('tool-1')).to.not.be.undefined;
|
||||
});
|
||||
|
||||
it('should handle complex mixed requests with all part types', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const toolRequest: ToolRequest = {
|
||||
id: 'analyze-tool',
|
||||
name: 'analyze',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
handler: async () => 'analysis'
|
||||
};
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: '@agent analyze #file using ~analyze-tool' },
|
||||
parts: [
|
||||
new ParsedChatRequestAgentPart({ start: 0, endExclusive: 6 }, 'agent-1', 'agent'),
|
||||
new ParsedChatRequestTextPart({ start: 6, endExclusive: 15 }, ' analyze '),
|
||||
(() => {
|
||||
const varPart = new ParsedChatRequestVariablePart({ start: 15, endExclusive: 20 }, 'file', undefined);
|
||||
varPart.resolution = {
|
||||
variable: { id: 'f', name: 'file', description: 'File' },
|
||||
value: 'code.ts'
|
||||
};
|
||||
return varPart;
|
||||
})(),
|
||||
new ParsedChatRequestTextPart({ start: 20, endExclusive: 27 }, ' using '),
|
||||
new ParsedChatRequestFunctionPart({ start: 27, endExclusive: 41 }, toolRequest)
|
||||
],
|
||||
toolRequests: new Map([['analyze-tool', toolRequest]]),
|
||||
variables: [{ variable: { id: 'f', name: 'file', description: 'File' }, value: 'code.ts' }]
|
||||
};
|
||||
model.addRequest(parsedRequest, 'agent-1');
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
const parsedReqData = serialized.requests[0].parsedRequest!;
|
||||
expect(parsedReqData.parts).to.have.lengthOf(5);
|
||||
expect(parsedReqData.parts[0].kind).to.equal('agent');
|
||||
expect(parsedReqData.parts[2].kind).to.equal('var');
|
||||
expect(parsedReqData.parts[4].kind).to.equal('function');
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredMsg = restored.getRequests()[0].message;
|
||||
expect(restoredMsg.parts).to.have.lengthOf(5);
|
||||
expect(restoredMsg.parts[0].kind).to.equal('agent');
|
||||
expect(restoredMsg.parts[2].kind).to.equal('var');
|
||||
expect(restoredMsg.parts[4].kind).to.equal('function');
|
||||
expect(restoredMsg.toolRequests.size).to.equal(1);
|
||||
expect(restoredMsg.variables).to.have.lengthOf(1);
|
||||
});
|
||||
|
||||
it('should handle fallback for requests without parsedRequest data', () => {
|
||||
const serializedData = {
|
||||
sessionId: 'test-session',
|
||||
location: ChatAgentLocation.Panel,
|
||||
hierarchy: {
|
||||
rootBranchId: 'root',
|
||||
branches: {
|
||||
'root': {
|
||||
id: 'root',
|
||||
items: [{ requestId: 'req-1' }],
|
||||
activeBranchIndex: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
requests: [
|
||||
{
|
||||
id: 'req-1',
|
||||
text: 'Plain text without parsed data'
|
||||
}
|
||||
],
|
||||
responses: []
|
||||
};
|
||||
|
||||
const model = new MutableChatModel(serializedData);
|
||||
const request = model.getRequests()[0];
|
||||
expect(request.message.parts).to.have.lengthOf(1);
|
||||
expect(request.message.parts[0].kind).to.equal('text');
|
||||
expect(request.message.parts[0].text).to.equal('Plain text without parsed data');
|
||||
});
|
||||
|
||||
it('should create placeholder tool requests during deserialization', async () => {
|
||||
const toolRequest: ToolRequest = {
|
||||
id: 'my-tool',
|
||||
name: 'myTool',
|
||||
description: 'My test tool',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' }
|
||||
},
|
||||
required: ['query']
|
||||
},
|
||||
handler: async () => 'tool result'
|
||||
};
|
||||
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: 'Use ~my-tool' },
|
||||
parts: [
|
||||
new ParsedChatRequestTextPart({ start: 0, endExclusive: 4 }, 'Use '),
|
||||
new ParsedChatRequestFunctionPart({ start: 4, endExclusive: 12 }, toolRequest)
|
||||
],
|
||||
toolRequests: new Map([['my-tool', toolRequest]]),
|
||||
variables: []
|
||||
};
|
||||
model.addRequest(parsedRequest);
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
expect(serialized.requests[0].parsedRequest).to.not.be.undefined;
|
||||
expect(serialized.requests[0].parsedRequest!.toolRequests).to.have.lengthOf(1);
|
||||
expect(serialized.requests[0].parsedRequest!.toolRequests[0].id).to.equal('my-tool');
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredRequest = restored.getRequests()[0];
|
||||
|
||||
// Verify placeholder was created
|
||||
expect(restoredRequest.message.parts[1].kind).to.equal('function');
|
||||
const funcPart = restoredRequest.message.parts[1] as ParsedChatRequestFunctionPart;
|
||||
expect(funcPart.toolRequest.id).to.equal('my-tool');
|
||||
|
||||
// Verify it's a placeholder (handler should throw about not being restored)
|
||||
try {
|
||||
await funcPart.toolRequest.handler('test-input');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect((error as Error).message).to.include('not yet restored');
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve variable arguments during serialization', () => {
|
||||
const model = new MutableChatModel(ChatAgentLocation.Panel);
|
||||
const varPartWithArg = new ParsedChatRequestVariablePart(
|
||||
{ start: 0, endExclusive: 10 },
|
||||
'file',
|
||||
'main.ts'
|
||||
);
|
||||
varPartWithArg.resolution = {
|
||||
variable: { id: 'f', name: 'file', description: 'File variable' },
|
||||
arg: 'main.ts',
|
||||
value: 'file content of main.ts'
|
||||
};
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: '#file:main.ts' },
|
||||
parts: [varPartWithArg],
|
||||
toolRequests: new Map(),
|
||||
variables: [varPartWithArg.resolution]
|
||||
};
|
||||
model.addRequest(parsedRequest);
|
||||
|
||||
const serialized = model.toSerializable();
|
||||
const serializedVar = serialized.requests[0].parsedRequest!.parts[0] as SerializableVariablePart;
|
||||
expect(serializedVar.variableId).to.equal('f');
|
||||
expect(serializedVar.variableArg).to.equal('main.ts');
|
||||
expect(serializedVar.variableValue).to.equal('file content of main.ts');
|
||||
expect(serializedVar.variableDescription).to.equal('File variable');
|
||||
|
||||
const restored = new MutableChatModel(serialized);
|
||||
const restoredPart = restored.getRequests()[0].message.parts[0] as ParsedChatRequestVariablePart;
|
||||
expect(restoredPart.variableArg).to.equal('main.ts');
|
||||
expect(restoredPart.resolution?.variable.id).to.equal('f');
|
||||
expect(restoredPart.resolution?.value).to.equal('file content of main.ts');
|
||||
expect(restoredPart.resolution?.variable.description).to.equal('File variable');
|
||||
});
|
||||
});
|
||||
});
|
||||
189
packages/ai-chat/src/common/chat-model-serialization.ts
Normal file
189
packages/ai-chat/src/common/chat-model-serialization.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ChatAgentLocation } from './chat-agents';
|
||||
|
||||
export interface SerializableChangeSetElement {
|
||||
kind?: string;
|
||||
uri: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
additionalInfo?: string;
|
||||
state?: 'pending' | 'applied' | 'stale';
|
||||
type?: 'add' | 'modify' | 'delete';
|
||||
data?: { [key: string]: unknown };
|
||||
}
|
||||
|
||||
export interface SerializableChangeSetFileElementData {
|
||||
targetState?: string;
|
||||
originalState?: string;
|
||||
replacements?: Array<{
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
multiple?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SerializableParsedRequestPartBase {
|
||||
range: { start: number; endExclusive: number };
|
||||
}
|
||||
|
||||
export interface SerializableTextPart extends SerializableParsedRequestPartBase {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SerializableVariablePart extends SerializableParsedRequestPartBase {
|
||||
kind: 'var';
|
||||
variableId: string;
|
||||
variableName: string;
|
||||
variableDescription: string;
|
||||
variableArg?: string;
|
||||
variableValue?: string;
|
||||
}
|
||||
|
||||
export interface SerializableFunctionPart extends SerializableParsedRequestPartBase {
|
||||
kind: 'function';
|
||||
toolRequestId: string;
|
||||
}
|
||||
|
||||
export interface SerializableAgentPart extends SerializableParsedRequestPartBase {
|
||||
kind: 'agent';
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export type SerializableParsedRequestPart =
|
||||
| SerializableTextPart
|
||||
| SerializableVariablePart
|
||||
| SerializableFunctionPart
|
||||
| SerializableAgentPart;
|
||||
|
||||
export interface SerializableToolRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SerializableResolvedVariable {
|
||||
variableId: string;
|
||||
variableName: string;
|
||||
variableDescription: string;
|
||||
arg?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SerializableParsedRequest {
|
||||
parts: SerializableParsedRequestPart[];
|
||||
toolRequests: SerializableToolRequest[];
|
||||
variables: SerializableResolvedVariable[];
|
||||
}
|
||||
|
||||
export interface SerializableChatRequestData {
|
||||
id: string;
|
||||
text: string;
|
||||
agentId?: string;
|
||||
changeSet?: {
|
||||
title: string;
|
||||
elements: SerializableChangeSetElement[];
|
||||
};
|
||||
parsedRequest?: SerializableParsedRequest;
|
||||
}
|
||||
|
||||
export interface SerializableChatResponseContentData<T = unknown> {
|
||||
kind: string;
|
||||
/**
|
||||
* Fallback message used when the deserializer for this content type is not available.
|
||||
*/
|
||||
fallbackMessage?: string;
|
||||
data: T; // Content-specific serialization
|
||||
}
|
||||
|
||||
export interface SerializableChatResponseData {
|
||||
id: string;
|
||||
requestId: string;
|
||||
isComplete: boolean;
|
||||
isError: boolean;
|
||||
errorMessage?: string;
|
||||
promptVariantId?: string;
|
||||
isPromptVariantEdited?: boolean;
|
||||
content: SerializableChatResponseContentData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized representation of an item in a hierarchy branch.
|
||||
* Each item represents a request and optionally links to the next branch.
|
||||
*/
|
||||
export interface SerializableHierarchyBranchItem {
|
||||
requestId: string;
|
||||
nextBranchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized representation of a branch in the chat request hierarchy.
|
||||
* A branch contains alternative requests (created by editing messages).
|
||||
*/
|
||||
export interface SerializableHierarchyBranch {
|
||||
/** Unique identifier for this branch */
|
||||
id: string;
|
||||
/** All items (alternative requests) in this branch */
|
||||
items: SerializableHierarchyBranchItem[];
|
||||
/** Index of the currently active item in this branch */
|
||||
activeBranchIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized representation of the complete chat request hierarchy.
|
||||
* The hierarchy is stored as a flat map of branches.
|
||||
*/
|
||||
export interface SerializableHierarchy {
|
||||
/** ID of the root branch where the hierarchy starts */
|
||||
rootBranchId: string;
|
||||
/** Map of branch ID to branch data for all branches in the hierarchy */
|
||||
branches: { [branchId: string]: SerializableHierarchyBranch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized representation of ChatModel.
|
||||
*/
|
||||
export interface SerializedChatModel {
|
||||
sessionId: string;
|
||||
location: ChatAgentLocation;
|
||||
/**
|
||||
* The complete hierarchy of requests including all alternatives (branches).
|
||||
*/
|
||||
hierarchy: SerializableHierarchy;
|
||||
/** All requests referenced by the hierarchy */
|
||||
requests: SerializableChatRequestData[];
|
||||
/** All responses for the requests */
|
||||
responses: SerializableChatResponseData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for persisted chat model data.
|
||||
* Includes metadata (version, pinned agent, title) along with the chat model.
|
||||
*/
|
||||
export interface SerializedChatData {
|
||||
version: number;
|
||||
pinnedAgentId?: string;
|
||||
title?: string;
|
||||
model: SerializedChatModel;
|
||||
saveDate: number;
|
||||
}
|
||||
|
||||
export interface SerializableChatsData {
|
||||
[sessionId: string]: SerializedChatData;
|
||||
}
|
||||
|
||||
export const CHAT_DATA_VERSION = 1;
|
||||
44
packages/ai-chat/src/common/chat-model-util.ts
Normal file
44
packages/ai-chat/src/common/chat-model-util.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { ChatProgressMessage, ChatRequestModel, ChatResponse, ChatResponseContent, ChatResponseModel, QuestionResponseContent } from './chat-model';
|
||||
|
||||
export function lastResponseContent(request: ChatRequestModel): ChatResponseContent | undefined {
|
||||
return lastContentOfResponse(request.response?.response);
|
||||
}
|
||||
|
||||
export function lastContentOfResponse(response: ChatResponse | undefined): ChatResponseContent | undefined {
|
||||
const content = response?.content;
|
||||
return content && content.length > 0 ? content[content.length - 1] : undefined;
|
||||
}
|
||||
|
||||
export function lastProgressMessage(request: ChatRequestModel): ChatProgressMessage | undefined {
|
||||
return lastProgressMessageOfResponse(request.response);
|
||||
}
|
||||
|
||||
export function lastProgressMessageOfResponse(response: ChatResponseModel | undefined): ChatProgressMessage | undefined {
|
||||
const progressMessages = response?.progressMessages;
|
||||
return progressMessages && progressMessages.length > 0 ? progressMessages[progressMessages.length - 1] : undefined;
|
||||
}
|
||||
|
||||
export function unansweredQuestions(request: ChatRequestModel): QuestionResponseContent[] {
|
||||
const response = request.response;
|
||||
return unansweredQuestionsOfResponse(response);
|
||||
}
|
||||
|
||||
function unansweredQuestionsOfResponse(response: ChatResponseModel | undefined): QuestionResponseContent[] {
|
||||
if (!response || !response.response) { return []; }
|
||||
return response.response.content.filter((c): c is QuestionResponseContent => QuestionResponseContent.is(c) && c.selectedOption === undefined);
|
||||
}
|
||||
2924
packages/ai-chat/src/common/chat-model.ts
Normal file
2924
packages/ai-chat/src/common/chat-model.ts
Normal file
File diff suppressed because it is too large
Load Diff
409
packages/ai-chat/src/common/chat-request-parser.spec.ts
Normal file
409
packages/ai-chat/src/common/chat-request-parser.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as sinon from 'sinon';
|
||||
import { ChatAgentServiceImpl } from './chat-agent-service';
|
||||
import { ChatRequestParserImpl } from './chat-request-parser';
|
||||
import { ChatAgent, ChatAgentLocation } from './chat-agents';
|
||||
import { ChatContext, ChatRequest } from './chat-model';
|
||||
import { expect } from 'chai';
|
||||
import { AIVariable, DefaultAIVariableService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core';
|
||||
import { ILogger, Logger } from '@theia/core';
|
||||
import { ParsedChatRequestAgentPart, ParsedChatRequestFunctionPart, ParsedChatRequestTextPart, ParsedChatRequestVariablePart } from './parsed-chat-request';
|
||||
import { AgentDelegationTool } from '../browser/agent-delegation-tool';
|
||||
|
||||
describe('ChatRequestParserImpl', () => {
|
||||
const chatAgentService = sinon.createStubInstance(ChatAgentServiceImpl);
|
||||
const variableService = sinon.createStubInstance(DefaultAIVariableService);
|
||||
const toolInvocationRegistry = sinon.createStubInstance(ToolInvocationRegistryImpl);
|
||||
const logger: ILogger = sinon.createStubInstance(Logger);
|
||||
const parser = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry, logger);
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset our stubs before each test
|
||||
sinon.reset();
|
||||
});
|
||||
|
||||
it('parses simple text', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: 'What is the best pizza topping?'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
expect(result.parts.length).to.equal(1);
|
||||
const part = result.parts[0] as ParsedChatRequestTextPart;
|
||||
expect(part.kind).to.equal('text');
|
||||
expect(part.text).to.equal('What is the best pizza topping?');
|
||||
expect(part.range).to.deep.equal({ start: 0, endExclusive: 31 });
|
||||
});
|
||||
|
||||
it('parses text with variable name', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: 'What is the #best pizza topping?'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
expect(result.parts.length).to.equal(3);
|
||||
|
||||
const textPart1 = result.parts[0] as ParsedChatRequestTextPart;
|
||||
expect(textPart1.kind).to.equal('text');
|
||||
expect(textPart1.text).to.equal('What is the ');
|
||||
expect(textPart1.range).to.deep.equal({ start: 0, endExclusive: 12 });
|
||||
|
||||
const varPart = result.parts[1] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.kind).to.equal('var');
|
||||
expect(varPart.variableName).to.equal('best');
|
||||
expect(varPart.variableArg).to.be.undefined;
|
||||
expect(varPart.range).to.deep.equal({ start: 12, endExclusive: 17 });
|
||||
|
||||
const textPart2 = result.parts[2] as ParsedChatRequestTextPart;
|
||||
expect(textPart2.kind).to.equal('text');
|
||||
expect(textPart2.text).to.equal(' pizza topping?');
|
||||
expect(textPart2.range).to.deep.equal({ start: 17, endExclusive: 32 });
|
||||
});
|
||||
|
||||
it('parses text with variable name with argument', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: 'What is the #best:by-poll pizza topping?'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
expect(result.parts.length).to.equal(3);
|
||||
|
||||
const textPart1 = result.parts[0] as ParsedChatRequestTextPart;
|
||||
expect(textPart1.kind).to.equal('text');
|
||||
expect(textPart1.text).to.equal('What is the ');
|
||||
expect(textPart1.range).to.deep.equal({ start: 0, endExclusive: 12 });
|
||||
|
||||
const varPart = result.parts[1] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.kind).to.equal('var');
|
||||
expect(varPart.variableName).to.equal('best');
|
||||
expect(varPart.variableArg).to.equal('by-poll');
|
||||
expect(varPart.range).to.deep.equal({ start: 12, endExclusive: 25 });
|
||||
|
||||
const textPart2 = result.parts[2] as ParsedChatRequestTextPart;
|
||||
expect(textPart2.kind).to.equal('text');
|
||||
expect(textPart2.text).to.equal(' pizza topping?');
|
||||
expect(textPart2.range).to.deep.equal({ start: 25, endExclusive: 40 });
|
||||
});
|
||||
|
||||
it('parses text with variable name with numeric argument', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '#size-class:2'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
expect(result.parts[0]).to.contain(
|
||||
{
|
||||
variableName: 'size-class',
|
||||
variableArg: '2'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('parses text with variable name with POSIX path argument', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '#file:/path/to/file.ext'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
expect(result.parts[0]).to.contain(
|
||||
{
|
||||
variableName: 'file',
|
||||
variableArg: '/path/to/file.ext'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('parses text with variable name with Win32 path argument', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '#file:c:\\path\\to\\file.ext'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
expect(result.parts[0]).to.contain(
|
||||
{
|
||||
variableName: 'file',
|
||||
variableArg: 'c:\\path\\to\\file.ext'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves variable and extracts tool functions from resolved variable', async () => {
|
||||
// Set up two test tool requests that will be referenced in the variable content
|
||||
const testTool1: ToolRequest = {
|
||||
id: 'testTool1',
|
||||
name: 'Test Tool 1',
|
||||
handler: async () => undefined,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
};
|
||||
const testTool2: ToolRequest = {
|
||||
id: 'testTool2',
|
||||
name: 'Test Tool 2',
|
||||
handler: async () => undefined,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
};
|
||||
// Configure the tool registry to return our test tools
|
||||
toolInvocationRegistry.getFunction.withArgs(testTool1.id).returns(testTool1);
|
||||
toolInvocationRegistry.getFunction.withArgs(testTool2.id).returns(testTool2);
|
||||
|
||||
// Set up the test variable to include in the request
|
||||
const testVariable: AIVariable = {
|
||||
id: 'testVariable',
|
||||
name: 'testVariable',
|
||||
description: 'A test variable',
|
||||
};
|
||||
// Configure the variable service to return our test variable
|
||||
// One tool reference uses chat format and one uses prompt format because the parser needs to handle both.
|
||||
variableService.getVariable.withArgs(testVariable.name).returns(testVariable);
|
||||
variableService.resolveVariable.withArgs(
|
||||
{ variable: testVariable.name, arg: 'myarg' },
|
||||
sinon.match.any
|
||||
).resolves({
|
||||
variable: testVariable,
|
||||
arg: 'myarg',
|
||||
value: 'This is a test with ~testTool1 and **~{testTool2}** and more text.',
|
||||
});
|
||||
|
||||
// Create a request with the test variable
|
||||
const req: ChatRequest = {
|
||||
text: 'Test with #testVariable:myarg'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
|
||||
// Parse the request
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
|
||||
// Verify the variable part contains the correct properties
|
||||
expect(result.parts.length).to.equal(2);
|
||||
expect(result.parts[0] instanceof ParsedChatRequestTextPart).to.be.true;
|
||||
expect(result.parts[1] instanceof ParsedChatRequestVariablePart).to.be.true;
|
||||
const variablePart = result.parts[1] as ParsedChatRequestVariablePart;
|
||||
expect(variablePart).to.have.property('resolution');
|
||||
expect(variablePart.resolution).to.deep.equal({
|
||||
variable: testVariable,
|
||||
arg: 'myarg',
|
||||
value: 'This is a test with ~testTool1 and **~{testTool2}** and more text.',
|
||||
} satisfies ResolvedAIVariable);
|
||||
|
||||
// Verify both tool functions were extracted from the variable content
|
||||
expect(result.toolRequests.size).to.equal(2);
|
||||
expect(result.toolRequests.has(testTool1.id)).to.be.true;
|
||||
expect(result.toolRequests.has(testTool2.id)).to.be.true;
|
||||
|
||||
// Verify the result contains the tool requests returned by the registry
|
||||
expect(result.toolRequests.get(testTool1.id)).to.deep.equal(testTool1);
|
||||
expect(result.toolRequests.get(testTool2.id)).to.deep.equal(testTool2);
|
||||
});
|
||||
|
||||
it('parses simple command without arguments', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '/hello'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
|
||||
expect(result.parts.length).to.equal(1);
|
||||
expect(result.parts[0] instanceof ParsedChatRequestVariablePart).to.be.true;
|
||||
const varPart = result.parts[0] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.variableName).to.equal('prompt');
|
||||
expect(varPart.variableArg).to.equal('hello');
|
||||
});
|
||||
|
||||
it('parses command with single argument', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '/explain topic'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
|
||||
expect(result.parts.length).to.equal(1);
|
||||
const varPart = result.parts[0] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.variableName).to.equal('prompt');
|
||||
expect(varPart.variableArg).to.equal('explain|topic');
|
||||
});
|
||||
|
||||
it('parses command with multiple arguments', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '/compare item1 item2'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
|
||||
const varPart = result.parts[0] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.variableName).to.equal('prompt');
|
||||
expect(varPart.variableArg).to.equal('compare|item1 item2');
|
||||
});
|
||||
|
||||
it('parses command with quoted arguments', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '/cmd "arg with spaces" other'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
|
||||
const varPart = result.parts[0] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.variableArg).to.equal('cmd|"arg with spaces" other');
|
||||
});
|
||||
|
||||
it('handles command with escaped quotes', async () => {
|
||||
const req: ChatRequest = {
|
||||
text: '/cmd "arg with \\"quote\\"" other'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
|
||||
const varPart = result.parts[0] as ParsedChatRequestVariablePart;
|
||||
expect(varPart.variableArg).to.equal('cmd|"arg with \\"quote\\"" other');
|
||||
});
|
||||
|
||||
it('treats the first @agent mention as the selector and does not allow later mentions to override it', async () => {
|
||||
const createAgent = (id: string): ChatAgent => ({
|
||||
id,
|
||||
name: id,
|
||||
description: '',
|
||||
tags: [],
|
||||
variables: [],
|
||||
prompts: [],
|
||||
agentSpecificVariables: [],
|
||||
functions: [],
|
||||
languageModelRequirements: [],
|
||||
locations: [ChatAgentLocation.Panel],
|
||||
invoke: async () => undefined,
|
||||
});
|
||||
const req: ChatRequest = {
|
||||
text: '@agentA do X @agentB do Y'
|
||||
};
|
||||
const context: ChatContext = { variables: [] };
|
||||
|
||||
chatAgentService.getAgents.returns([
|
||||
createAgent('agentA'),
|
||||
createAgent('agentB'),
|
||||
]);
|
||||
|
||||
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
||||
const agentParts = result.parts.filter(p => p instanceof ParsedChatRequestAgentPart) as ParsedChatRequestAgentPart[];
|
||||
|
||||
expect(agentParts.length).to.equal(1);
|
||||
expect(agentParts[0].agentId).to.equal('agentA');
|
||||
expect(agentParts[0].agentName).to.equal('agentA');
|
||||
});
|
||||
|
||||
it('delegateToAgent(agentId, prompt) composes a request that forces selecting agentId even if prompt mentions other agents', async () => {
|
||||
const createAgent = (id: string): ChatAgent => ({
|
||||
id,
|
||||
name: id,
|
||||
description: '',
|
||||
tags: [],
|
||||
variables: [],
|
||||
prompts: [],
|
||||
agentSpecificVariables: [],
|
||||
functions: [],
|
||||
languageModelRequirements: [],
|
||||
locations: [ChatAgentLocation.Panel],
|
||||
invoke: async () => undefined,
|
||||
});
|
||||
|
||||
const tool = new AgentDelegationTool();
|
||||
(tool as unknown as { getChatAgentService: () => unknown }).getChatAgentService = () => ({
|
||||
getAgent: sinon.stub().withArgs('agentA').returns(createAgent('agentA')),
|
||||
getAgents: sinon.stub().returns([createAgent('agentA')]),
|
||||
});
|
||||
|
||||
const sendRequest = sinon.stub().callsFake(async (_sessionId: string, request: ChatRequest) => {
|
||||
const parseResult = await parser.parseChatRequest(request, ChatAgentLocation.Panel, { variables: [] });
|
||||
const agentParts = parseResult.parts.filter(p => p instanceof ParsedChatRequestAgentPart) as ParsedChatRequestAgentPart[];
|
||||
expect(agentParts.length).to.equal(1);
|
||||
expect(agentParts[0].agentId).to.equal('agentA');
|
||||
|
||||
return {
|
||||
requestCompleted: Promise.resolve({ cancel: () => undefined }),
|
||||
responseCompleted: Promise.resolve({ response: { asString: () => 'ok' } }),
|
||||
};
|
||||
});
|
||||
|
||||
(tool as unknown as { getChatService: () => unknown }).getChatService = () => ({
|
||||
getActiveSession: sinon.stub().returns(undefined),
|
||||
setActiveSession: sinon.stub(),
|
||||
createSession: sinon.stub().returns({
|
||||
id: 'session-1',
|
||||
model: {
|
||||
changeSet: {
|
||||
onDidChange: sinon.stub().returns({}),
|
||||
getElements: sinon.stub().returns([]),
|
||||
setTitle: sinon.stub(),
|
||||
addElements: sinon.stub(),
|
||||
}
|
||||
}
|
||||
}),
|
||||
sendRequest,
|
||||
deleteSession: sinon.stub().resolves(undefined),
|
||||
});
|
||||
|
||||
const toolRequest = tool.getTool();
|
||||
await toolRequest.handler(
|
||||
JSON.stringify({ agentId: 'agentA', prompt: 'do X @agentB do Y' }),
|
||||
{
|
||||
cancellationToken: { isCancellationRequested: false, onCancellationRequested: sinon.stub() },
|
||||
request: {
|
||||
session: { changeSet: { setTitle: sinon.stub(), addElements: sinon.stub() } },
|
||||
},
|
||||
response: {
|
||||
cancellationToken: { isCancellationRequested: false, onCancellationRequested: sinon.stub() },
|
||||
response: { addContent: sinon.stub() },
|
||||
},
|
||||
} as unknown as Parameters<typeof toolRequest.handler>[1]
|
||||
);
|
||||
|
||||
expect(sendRequest.calledOnce).to.be.true;
|
||||
const delegatedChatRequest = sendRequest.firstCall.args[1] as ChatRequest;
|
||||
expect(delegatedChatRequest.text).to.equal('@agentA do X @agentB do Y');
|
||||
});
|
||||
|
||||
describe('parsed chat request part kind assignments', () => {
|
||||
it('ParsedChatRequestTextPart has kind assigned at runtime', () => {
|
||||
const part = new ParsedChatRequestTextPart({ start: 0, endExclusive: 5 }, 'hello');
|
||||
expect(part.kind).to.equal('text');
|
||||
});
|
||||
|
||||
it('ParsedChatRequestVariablePart has kind assigned at runtime', () => {
|
||||
const part = new ParsedChatRequestVariablePart({ start: 0, endExclusive: 5 }, 'varName', undefined);
|
||||
expect(part.kind).to.equal('var');
|
||||
});
|
||||
|
||||
it('ParsedChatRequestFunctionPart has kind assigned at runtime', () => {
|
||||
const toolRequest: ToolRequest = {
|
||||
id: 'testTool',
|
||||
name: 'Test Tool',
|
||||
handler: async () => undefined,
|
||||
parameters: { type: 'object', properties: {} }
|
||||
};
|
||||
const part = new ParsedChatRequestFunctionPart({ start: 0, endExclusive: 5 }, toolRequest);
|
||||
expect(part.kind).to.equal('function');
|
||||
});
|
||||
|
||||
it('ParsedChatRequestAgentPart has kind assigned at runtime', () => {
|
||||
const part = new ParsedChatRequestAgentPart({ start: 0, endExclusive: 5 }, 'agentId', 'agentName');
|
||||
expect(part.kind).to.equal('agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
307
packages/ai-chat/src/common/chat-request-parser.ts
Normal file
307
packages/ai-chat/src/common/chat-request-parser.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatRequestParser.ts
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
import { ChatAgentLocation } from './chat-agents';
|
||||
import { ChatContext, ChatRequest } from './chat-model';
|
||||
import {
|
||||
chatAgentLeader,
|
||||
chatFunctionLeader,
|
||||
ParsedChatRequestAgentPart,
|
||||
ParsedChatRequestFunctionPart,
|
||||
ParsedChatRequestTextPart,
|
||||
ParsedChatRequestVariablePart,
|
||||
chatVariableLeader,
|
||||
chatSubcommandLeader,
|
||||
OffsetRange,
|
||||
ParsedChatRequest,
|
||||
ParsedChatRequestPart,
|
||||
} from './parsed-chat-request';
|
||||
import { AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
|
||||
import { ILogger } from '@theia/core';
|
||||
|
||||
const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent
|
||||
const functionReg = /^~([\w_\-\.]+)(?=(\s|$|\b))/i; // A ~ tool function
|
||||
const functionPromptFormatReg = /^\~\{\s*(.*?)\s*\}/i; // A ~{} prompt-format tool function
|
||||
const variableReg = /^#([\w_\-]+)(?::([\w_\-_\/\\.:]+))?(?=(\s|$|\b))/i; // A #-variable with an optional : arg (#file:workspace/path/name.ext)
|
||||
const commandReg = /^\/([\w_\-]+)(?:\s+(.+?))?(?=\s*$)/; // A /-command with optional arguments (/commandname arg1 arg2)
|
||||
|
||||
export const ChatRequestParser = Symbol('ChatRequestParser');
|
||||
export interface ChatRequestParser {
|
||||
parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise<ParsedChatRequest>;
|
||||
}
|
||||
|
||||
function offsetRange(start: number, endExclusive: number): OffsetRange {
|
||||
if (start > endExclusive) {
|
||||
throw new Error(`Invalid range: start=${start} endExclusive=${endExclusive}`);
|
||||
}
|
||||
return { start, endExclusive };
|
||||
}
|
||||
@injectable()
|
||||
export class ChatRequestParserImpl implements ChatRequestParser {
|
||||
constructor(
|
||||
@inject(ChatAgentService) private readonly agentService: ChatAgentService,
|
||||
@inject(AIVariableService) private readonly variableService: AIVariableService,
|
||||
@inject(ToolInvocationRegistry) private readonly toolInvocationRegistry: ToolInvocationRegistry,
|
||||
@inject(ILogger) private readonly logger: ILogger
|
||||
) { }
|
||||
|
||||
async parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise<ParsedChatRequest> {
|
||||
// Parse the request into parts
|
||||
const { parts, toolRequests } = this.parseParts(request, location);
|
||||
|
||||
// Resolve all variables and add them to the variable parts.
|
||||
// Parse resolved variable texts again for tool requests.
|
||||
// These are not added to parts as they are not visible in the initial chat message.
|
||||
// However, they need to be added to the result to be considered by the executing agent.
|
||||
const variableCache = createAIResolveVariableCache();
|
||||
for (const part of parts) {
|
||||
if (part instanceof ParsedChatRequestVariablePart) {
|
||||
const resolvedVariable = await this.variableService.resolveVariable(
|
||||
{ variable: part.variableName, arg: part.variableArg },
|
||||
context,
|
||||
variableCache
|
||||
);
|
||||
if (resolvedVariable) {
|
||||
part.resolution = resolvedVariable;
|
||||
// Resolve tool requests in resolved variables
|
||||
this.parseFunctionsFromVariableText(resolvedVariable.value, toolRequests);
|
||||
} else {
|
||||
this.logger.warn(`Failed to resolve variable ${part.variableName} for ${location}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get resolved variables from variable cache after all variables have been resolved.
|
||||
// We want to return all recursively resolved variables, thus use the whole cache.
|
||||
const resolvedVariables = await getAllResolvedAIVariables(variableCache);
|
||||
|
||||
return { request, parts, toolRequests, variables: resolvedVariables };
|
||||
}
|
||||
|
||||
protected parseParts(request: ChatRequest, location: ChatAgentLocation): {
|
||||
parts: ParsedChatRequestPart[];
|
||||
toolRequests: Map<string, ToolRequest>;
|
||||
variables: Map<string, AIVariable>;
|
||||
} {
|
||||
const parts: ParsedChatRequestPart[] = [];
|
||||
const variables = new Map<string, AIVariable>();
|
||||
const toolRequests = new Map<string, ToolRequest>();
|
||||
if (!request.text) {
|
||||
return { parts, toolRequests, variables };
|
||||
}
|
||||
const message = request.text;
|
||||
for (let i = 0; i < message.length; i++) {
|
||||
const previousChar = message.charAt(i - 1);
|
||||
const char = message.charAt(i);
|
||||
let newPart: ParsedChatRequestPart | undefined;
|
||||
|
||||
if (previousChar.match(/\s/) || i === 0) {
|
||||
if (char === chatSubcommandLeader) {
|
||||
// Try to parse as command - commands are syntactic sugar for #prompt:commandName|args
|
||||
const commandPart = this.tryToParseCommand(
|
||||
message.slice(i),
|
||||
i,
|
||||
parts
|
||||
);
|
||||
if (commandPart) {
|
||||
newPart = commandPart;
|
||||
const variable = this.variableService.getVariable(commandPart.variableName);
|
||||
if (variable) {
|
||||
variables.set(variable.name, variable);
|
||||
}
|
||||
}
|
||||
} else if (char === chatFunctionLeader) {
|
||||
const functionPart = this.tryToParseFunction(
|
||||
message.slice(i),
|
||||
i
|
||||
);
|
||||
newPart = functionPart;
|
||||
if (functionPart) {
|
||||
toolRequests.set(functionPart.toolRequest.id, functionPart.toolRequest);
|
||||
}
|
||||
} else if (char === chatVariableLeader) {
|
||||
const variablePart = this.tryToParseVariable(
|
||||
message.slice(i),
|
||||
i,
|
||||
parts
|
||||
);
|
||||
newPart = variablePart;
|
||||
if (variablePart) {
|
||||
const variable = this.variableService.getVariable(variablePart.variableName);
|
||||
if (variable) {
|
||||
variables.set(variable.name, variable);
|
||||
}
|
||||
}
|
||||
} else if (char === chatAgentLeader) {
|
||||
newPart = this.tryToParseAgent(
|
||||
message.slice(i),
|
||||
i,
|
||||
parts,
|
||||
location
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newPart) {
|
||||
if (i !== 0) {
|
||||
// Insert a part for all the text we passed over, then insert the new parsed part
|
||||
const previousPart = parts.at(-1);
|
||||
const previousPartEnd = previousPart?.range.endExclusive ?? 0;
|
||||
parts.push(
|
||||
new ParsedChatRequestTextPart(
|
||||
offsetRange(previousPartEnd, i),
|
||||
message.slice(previousPartEnd, i)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(newPart);
|
||||
}
|
||||
}
|
||||
|
||||
const lastPart = parts.at(-1);
|
||||
const lastPartEnd = lastPart?.range.endExclusive ?? 0;
|
||||
if (lastPartEnd < message.length) {
|
||||
parts.push(
|
||||
new ParsedChatRequestTextPart(
|
||||
offsetRange(lastPartEnd, message.length),
|
||||
message.slice(lastPartEnd, message.length)
|
||||
)
|
||||
);
|
||||
}
|
||||
return { parts, toolRequests, variables };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse text for tool requests and add them to the given map
|
||||
*/
|
||||
private parseFunctionsFromVariableText(text: string, toolRequests: Map<string, ToolRequest>): void {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charAt(i);
|
||||
|
||||
// Check for function markers at start of words
|
||||
if (char === chatFunctionLeader) {
|
||||
const functionPart = this.tryToParseFunction(text.slice(i), i);
|
||||
if (functionPart) {
|
||||
// Add the found tool request to the given map
|
||||
toolRequests.set(functionPart.toolRequest.id, functionPart.toolRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tryToParseAgent(
|
||||
message: string,
|
||||
offset: number,
|
||||
parts: ReadonlyArray<ParsedChatRequestPart>,
|
||||
location: ChatAgentLocation
|
||||
): ParsedChatRequestAgentPart | ParsedChatRequestVariablePart | undefined {
|
||||
const nextAgentMatch = message.match(agentReg);
|
||||
if (!nextAgentMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [full, name] = nextAgentMatch;
|
||||
const agentRange = offsetRange(offset, offset + full.length);
|
||||
|
||||
let agents = this.agentService.getAgents().filter(a => a.name === name);
|
||||
if (!agents.length) {
|
||||
const fqAgent = this.agentService.getAgent(name);
|
||||
if (fqAgent) {
|
||||
agents = [fqAgent];
|
||||
}
|
||||
}
|
||||
|
||||
// If there is more than one agent with this name, and the user picked it from the suggest widget, then the selected agent should be in the
|
||||
// context and we use that one. Otherwise just pick the first.
|
||||
const agent = agents[0];
|
||||
if (!agent || !agent.locations.includes(location)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.some(p => p instanceof ParsedChatRequestAgentPart)) {
|
||||
// Only one agent allowed
|
||||
return;
|
||||
}
|
||||
|
||||
return new ParsedChatRequestAgentPart(agentRange, agent.id, agent.name);
|
||||
}
|
||||
|
||||
private tryToParseVariable(
|
||||
message: string,
|
||||
offset: number,
|
||||
_parts: ReadonlyArray<ParsedChatRequestPart>
|
||||
): ParsedChatRequestVariablePart | undefined {
|
||||
const nextVariableMatch = message.match(variableReg);
|
||||
if (!nextVariableMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [full, name] = nextVariableMatch;
|
||||
const variableArg = nextVariableMatch[2];
|
||||
const varRange = offsetRange(offset, offset + full.length);
|
||||
|
||||
return new ParsedChatRequestVariablePart(varRange, name, variableArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a command at the start of the given message.
|
||||
*
|
||||
* Commands are syntactic sugar for `#prompt:commandName|args`.
|
||||
* The prompt variable resolver will handle the actual resolution.
|
||||
*/
|
||||
protected tryToParseCommand(
|
||||
message: string,
|
||||
offset: number,
|
||||
_parts: ReadonlyArray<ParsedChatRequestPart>
|
||||
): ParsedChatRequestVariablePart | undefined {
|
||||
const nextCommandMatch = message.match(commandReg);
|
||||
if (!nextCommandMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [full, commandName, commandArgs] = nextCommandMatch;
|
||||
const commandRange = offsetRange(offset, offset + full.length);
|
||||
|
||||
const variableArg = commandArgs ? `${commandName}|${commandArgs}` : commandName;
|
||||
return new ParsedChatRequestVariablePart(commandRange, 'prompt', variableArg);
|
||||
}
|
||||
|
||||
private tryToParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined {
|
||||
// Support both the and chat and prompt formats for functions
|
||||
const nextFunctionMatch = message.match(functionPromptFormatReg) || message.match(functionReg);
|
||||
if (!nextFunctionMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [full, id] = nextFunctionMatch;
|
||||
|
||||
const maybeToolRequest = this.toolInvocationRegistry.getFunction(id);
|
||||
if (!maybeToolRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionRange = offsetRange(offset, offset + full.length);
|
||||
return new ParsedChatRequestFunctionPart(functionRange, maybeToolRequest);
|
||||
}
|
||||
}
|
||||
285
packages/ai-chat/src/common/chat-service-deletion.spec.ts
Normal file
285
packages/ai-chat/src/common/chat-service-deletion.spec.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { Container } from '@theia/core/shared/inversify';
|
||||
import { ChatServiceImpl } from './chat-service';
|
||||
import { ChatSessionStore, ChatSessionIndex, ChatModelWithMetadata } from './chat-session-store';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
import { ChatRequestParser } from './chat-request-parser';
|
||||
import { AIVariableService, ToolInvocationRegistry } from '@theia/ai-core';
|
||||
import { ILogger } from '@theia/core';
|
||||
import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer';
|
||||
import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer';
|
||||
import { ChatAgentLocation } from './chat-agents';
|
||||
import { ChatModel } from './chat-model';
|
||||
import { SerializedChatData } from './chat-model-serialization';
|
||||
|
||||
describe('ChatService Session Deletion', () => {
|
||||
let chatService: ChatServiceImpl;
|
||||
let sessionStore: MockChatSessionStore;
|
||||
let container: Container;
|
||||
|
||||
class MockChatSessionStore implements ChatSessionStore {
|
||||
public deletedSessions: string[] = [];
|
||||
public storedSessions: Array<ChatModel | ChatModelWithMetadata> = [];
|
||||
|
||||
async storeSessions(...sessions: Array<ChatModel | ChatModelWithMetadata>): Promise<void> {
|
||||
this.storedSessions = sessions;
|
||||
}
|
||||
|
||||
async readSession(sessionId: string): Promise<SerializedChatData | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
this.deletedSessions.push(sessionId);
|
||||
}
|
||||
|
||||
async clearAllSessions(): Promise<void> {
|
||||
this.deletedSessions = [];
|
||||
this.storedSessions = [];
|
||||
}
|
||||
|
||||
async getSessionIndex(): Promise<ChatSessionIndex> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async hasPersistedSessions(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setSessionTitle(sessionId: string, title: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
class MockChatAgentService {
|
||||
getAgent(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
getAgents(): never[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockChatRequestParser {
|
||||
parseChatRequest(): { parts: never[]; text: string } {
|
||||
return { parts: [], text: '' };
|
||||
}
|
||||
}
|
||||
|
||||
class MockAIVariableService {
|
||||
resolveVariables(): Promise<unknown[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
class MockLogger {
|
||||
error(): void { }
|
||||
warn(): void { }
|
||||
info(): void { }
|
||||
debug(): void { }
|
||||
}
|
||||
|
||||
const mockToolInvocationRegistry: ToolInvocationRegistry = {
|
||||
registerTool: () => { },
|
||||
getFunction: () => undefined,
|
||||
getFunctions: () => [],
|
||||
getAllFunctions: () => [],
|
||||
unregisterAllTools: () => { },
|
||||
onDidChange: () => ({ dispose: () => { } })
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
sessionStore = new MockChatSessionStore();
|
||||
|
||||
container.bind(ChatSessionStore).toConstantValue(sessionStore);
|
||||
container.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
||||
container.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
container.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
container.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
container.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
||||
|
||||
// Bind deserializer registries
|
||||
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
||||
container.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
||||
container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
container.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
chatService = container.get(ChatServiceImpl);
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
it('should delete session from memory and persistent storage', async () => {
|
||||
// Create a session
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
expect(chatService.getSessions()).to.have.lengthOf(1);
|
||||
|
||||
// Delete the session (now returns a Promise)
|
||||
await chatService.deleteSession(session.id);
|
||||
|
||||
// Verify it's removed from memory
|
||||
expect(chatService.getSessions()).to.have.lengthOf(0);
|
||||
|
||||
// Verify it's deleted from persistent storage
|
||||
expect(sessionStore.deletedSessions).to.include(session.id);
|
||||
});
|
||||
|
||||
it('should emit SessionDeletedEvent when session is deleted', done => {
|
||||
// Create a session
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
|
||||
// Listen for deletion event
|
||||
chatService.onSessionEvent(event => {
|
||||
if (event.type === 'deleted') {
|
||||
expect(event.sessionId).to.equal(session.id);
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete the session
|
||||
chatService.deleteSession(session.id);
|
||||
});
|
||||
|
||||
it('should handle deletion when session store is not available', async () => {
|
||||
// Create a new service without session store
|
||||
const containerWithoutStore = new Container();
|
||||
containerWithoutStore.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
||||
containerWithoutStore.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
containerWithoutStore.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
containerWithoutStore.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
containerWithoutStore.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
||||
|
||||
// Bind deserializer registries
|
||||
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
||||
containerWithoutStore.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
||||
containerWithoutStore.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
|
||||
containerWithoutStore.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
const serviceWithoutStore = containerWithoutStore.get(ChatServiceImpl);
|
||||
|
||||
// Create and delete a session - should not throw
|
||||
const session = serviceWithoutStore.createSession(ChatAgentLocation.Panel);
|
||||
await serviceWithoutStore.deleteSession(session.id);
|
||||
|
||||
// Verify session is still removed from memory
|
||||
expect(serviceWithoutStore.getSessions()).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should attempt storage deletion even for non-existent in-memory sessions', async () => {
|
||||
const initialDeletedCount = sessionStore.deletedSessions.length;
|
||||
const nonExistentId = 'non-existent-id';
|
||||
|
||||
// Try to delete non-existent session (could be a persisted-only session)
|
||||
await chatService.deleteSession(nonExistentId);
|
||||
|
||||
// Verify storage deletion was attempted even though session not in memory
|
||||
expect(sessionStore.deletedSessions).to.include(nonExistentId);
|
||||
expect(sessionStore.deletedSessions).to.have.lengthOf(initialDeletedCount + 1);
|
||||
});
|
||||
|
||||
it('should handle deleting active session', () => {
|
||||
// Create two sessions
|
||||
const session1 = chatService.createSession(ChatAgentLocation.Panel);
|
||||
const session2 = chatService.createSession(ChatAgentLocation.Panel);
|
||||
|
||||
// Ensure session2 is active (it should be by default as the latest)
|
||||
expect(chatService.getActiveSession()?.id).to.equal(session2.id);
|
||||
|
||||
// Delete session1 (not active)
|
||||
chatService.deleteSession(session1.id);
|
||||
|
||||
// Verify session2 is still active
|
||||
const activeSession = chatService.getActiveSession();
|
||||
expect(activeSession).to.not.be.undefined;
|
||||
expect(activeSession?.id).to.equal(session2.id);
|
||||
});
|
||||
|
||||
it('should handle storage deletion errors gracefully', async () => {
|
||||
// Create a session store that throws errors
|
||||
const errorStore = new MockChatSessionStore();
|
||||
errorStore.deleteSession = async () => {
|
||||
throw new Error('Storage error');
|
||||
};
|
||||
|
||||
const errorContainer = new Container();
|
||||
errorContainer.bind(ChatSessionStore).toConstantValue(errorStore);
|
||||
errorContainer.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
||||
errorContainer.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
errorContainer.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
errorContainer.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
errorContainer.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
||||
|
||||
// Bind deserializer registries
|
||||
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
||||
errorContainer.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
||||
errorContainer.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
|
||||
errorContainer.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
const errorService = errorContainer.get(ChatServiceImpl);
|
||||
|
||||
// Create and delete a session - should not throw even with storage error
|
||||
const session = errorService.createSession(ChatAgentLocation.Panel);
|
||||
await errorService.deleteSession(session.id);
|
||||
|
||||
// Verify session is still removed from memory despite storage error
|
||||
expect(errorService.getSessions()).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should delete persisted-only sessions (not loaded in memory)', async () => {
|
||||
// This simulates deleting a session from the "Show Chats..." dialog
|
||||
// when the session is persisted but not currently loaded into memory
|
||||
const persistedSessionId = 'persisted-session-123';
|
||||
|
||||
// Verify session is not in memory
|
||||
expect(chatService.getSessions().find(s => s.id === persistedSessionId)).to.be.undefined;
|
||||
|
||||
// Delete the persisted-only session
|
||||
await chatService.deleteSession(persistedSessionId);
|
||||
|
||||
// Verify it was still deleted from storage (even though not in memory)
|
||||
expect(sessionStore.deletedSessions).to.include(persistedSessionId);
|
||||
});
|
||||
|
||||
it('should not fire SessionDeletedEvent for persisted-only sessions', async () => {
|
||||
// When deleting a persisted-only session (not in memory),
|
||||
// we shouldn't fire the event since no in-memory state changed
|
||||
const persistedSessionId = 'persisted-session-456';
|
||||
let eventFired = false;
|
||||
|
||||
// Listen for deletion event
|
||||
chatService.onSessionEvent(event => {
|
||||
if (event.type === 'deleted' && event.sessionId === persistedSessionId) {
|
||||
eventFired = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete the persisted-only session
|
||||
await chatService.deleteSession(persistedSessionId);
|
||||
|
||||
// Event should not have been fired since session wasn't in memory
|
||||
expect(eventFired).to.be.false;
|
||||
// But storage deletion should still have happened
|
||||
expect(sessionStore.deletedSessions).to.include(persistedSessionId);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
packages/ai-chat/src/common/chat-service-pinned-agent.spec.ts
Normal file
218
packages/ai-chat/src/common/chat-service-pinned-agent.spec.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { Container } from '@theia/core/shared/inversify';
|
||||
import { ChatServiceImpl, ChatSession, DefaultChatAgentId, PinChatAgent } from './chat-service';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
import { ChatRequestParser } from './chat-request-parser';
|
||||
import { AIVariableService, ToolInvocationRegistry } from '@theia/ai-core';
|
||||
import { ILogger } from '@theia/core';
|
||||
import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer';
|
||||
import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer';
|
||||
import { ChatAgent, ChatAgentLocation } from './chat-agents';
|
||||
import { ParsedChatRequest, ParsedChatRequestAgentPart, ParsedChatRequestTextPart } from './parsed-chat-request';
|
||||
import { ChatRequest } from './chat-model';
|
||||
|
||||
describe('ChatService pinned agent behavior', () => {
|
||||
let chatService: ChatServiceImpl;
|
||||
let mockAgentService: MockChatAgentService;
|
||||
let container: Container;
|
||||
|
||||
const mockPinnedAgent: ChatAgent = {
|
||||
id: 'pinned-agent',
|
||||
name: 'Pinned Agent',
|
||||
description: 'Test pinned agent',
|
||||
locations: [ChatAgentLocation.Panel],
|
||||
invoke: async () => { },
|
||||
languageModelRequirements: [],
|
||||
tags: [],
|
||||
variables: [],
|
||||
agentSpecificVariables: [],
|
||||
functions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const mockMentionedAgent: ChatAgent = {
|
||||
id: 'mentioned-agent',
|
||||
name: 'Mentioned Agent',
|
||||
description: 'Test mentioned agent',
|
||||
locations: [ChatAgentLocation.Panel],
|
||||
invoke: async () => { },
|
||||
languageModelRequirements: [],
|
||||
tags: [],
|
||||
variables: [],
|
||||
agentSpecificVariables: [],
|
||||
functions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
const mockDefaultAgent: ChatAgent = {
|
||||
id: 'default-agent',
|
||||
name: 'Default Agent',
|
||||
description: 'Test default agent',
|
||||
locations: [ChatAgentLocation.Panel],
|
||||
invoke: async () => { },
|
||||
languageModelRequirements: [],
|
||||
tags: [],
|
||||
variables: [],
|
||||
agentSpecificVariables: [],
|
||||
functions: [],
|
||||
prompts: []
|
||||
};
|
||||
|
||||
class MockChatAgentService {
|
||||
private agents = new Map<string, ChatAgent>([
|
||||
['pinned-agent', mockPinnedAgent],
|
||||
['mentioned-agent', mockMentionedAgent],
|
||||
['default-agent', mockDefaultAgent]
|
||||
]);
|
||||
|
||||
getAgent(id: string): ChatAgent | undefined {
|
||||
return this.agents.get(id);
|
||||
}
|
||||
|
||||
removeAgent(id: string): void {
|
||||
this.agents.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
class MockChatRequestParser {
|
||||
async parseChatRequest(request: ChatRequest): Promise<ParsedChatRequest> {
|
||||
const agentId = this.getMentionedAgentId(request.text);
|
||||
const parts = agentId
|
||||
? [
|
||||
new ParsedChatRequestAgentPart({ start: 0, endExclusive: agentId.length + 1 }, agentId, agentId),
|
||||
new ParsedChatRequestTextPart({ start: agentId.length + 2, endExclusive: request.text.length }, request.text.substring(agentId.length + 2))
|
||||
]
|
||||
: [
|
||||
new ParsedChatRequestTextPart({ start: 0, endExclusive: request.text.length }, request.text)
|
||||
];
|
||||
|
||||
return {
|
||||
request,
|
||||
parts,
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
};
|
||||
}
|
||||
|
||||
private getMentionedAgentId(text: string): string | undefined {
|
||||
if (!text.startsWith('@')) {
|
||||
return undefined;
|
||||
}
|
||||
const spaceIndex = text.indexOf(' ');
|
||||
if (spaceIndex === -1) {
|
||||
return text.substring(1) || undefined;
|
||||
}
|
||||
return text.substring(1, spaceIndex) || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class MockAIVariableService {
|
||||
async resolveVariable(): Promise<unknown> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class MockLogger {
|
||||
error(): void { }
|
||||
warn(): void { }
|
||||
info(): void { }
|
||||
debug(): void { }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = new Container();
|
||||
mockAgentService = new MockChatAgentService();
|
||||
|
||||
container.bind(ChatAgentService).toConstantValue(mockAgentService as unknown as ChatAgentService);
|
||||
container.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
||||
container.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
||||
container.bind(ToolInvocationRegistry).toConstantValue({});
|
||||
container.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
||||
|
||||
container.bind(DefaultChatAgentId).toConstantValue({ id: 'default-agent' });
|
||||
container.bind(PinChatAgent).toConstantValue(true);
|
||||
|
||||
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
||||
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
||||
container.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
||||
container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
||||
container.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
||||
|
||||
chatService = container.get(ChatServiceImpl);
|
||||
});
|
||||
|
||||
function createMockSession(pinnedAgent?: ChatAgent): ChatSession {
|
||||
const session = chatService.createSession(ChatAgentLocation.Panel);
|
||||
if (pinnedAgent) {
|
||||
session.pinnedAgent = pinnedAgent;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
it('should preserve precedence: mentioned agent > pinned agent > default', () => {
|
||||
const session = createMockSession(mockPinnedAgent);
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: '@mentioned-agent test' },
|
||||
parts: [
|
||||
new ParsedChatRequestAgentPart({ start: 0, endExclusive: '@mentioned-agent'.length }, 'mentioned-agent', 'mentioned-agent'),
|
||||
new ParsedChatRequestTextPart({ start: '@mentioned-agent '.length, endExclusive: '@mentioned-agent test'.length }, 'test')
|
||||
],
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
};
|
||||
|
||||
const agent = chatService.getAgent(parsedRequest, session);
|
||||
expect(agent).to.equal(mockMentionedAgent);
|
||||
});
|
||||
|
||||
it('should return pinned agent when no mention exists', () => {
|
||||
const session = createMockSession(mockPinnedAgent);
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: 'test message' },
|
||||
parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: 'test message'.length }, 'test message')],
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
};
|
||||
|
||||
const agent = chatService.getAgent(parsedRequest, session);
|
||||
expect(agent).to.equal(mockPinnedAgent);
|
||||
});
|
||||
|
||||
it('should return default agent when no mention and no pinned agent exist', () => {
|
||||
const session = createMockSession(undefined);
|
||||
const parsedRequest: ParsedChatRequest = {
|
||||
request: { text: 'test message' },
|
||||
parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: 'test message'.length }, 'test message')],
|
||||
toolRequests: new Map(),
|
||||
variables: []
|
||||
};
|
||||
|
||||
const agent = chatService.getAgent(parsedRequest, session);
|
||||
expect(agent).to.equal(mockDefaultAgent);
|
||||
});
|
||||
|
||||
it('should auto-pin selected agent during sendRequest', async () => {
|
||||
const session = createMockSession(mockPinnedAgent);
|
||||
|
||||
await chatService.sendRequest(session.id, { text: '@mentioned-agent test' });
|
||||
|
||||
// The selected agent (mentioned-agent) becomes the new pinned agent
|
||||
expect(session.pinnedAgent).to.equal(mockMentionedAgent);
|
||||
});
|
||||
});
|
||||
663
packages/ai-chat/src/common/chat-service.ts
Normal file
663
packages/ai-chat/src/common/chat-service.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatService.ts
|
||||
|
||||
import { AIVariableResolutionRequest, AIVariableService, ResolvedAIContextVariable, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
|
||||
import { Emitter, ILogger, URI, generateUuid } from '@theia/core';
|
||||
import { Deferred } from '@theia/core/lib/common/promise-util';
|
||||
import { inject, injectable, optional } from '@theia/core/shared/inversify';
|
||||
import { Event } from '@theia/core/shared/vscode-languageserver-protocol';
|
||||
import { ChatAgentService } from './chat-agent-service';
|
||||
import { ChatAgent, ChatAgentLocation, ChatSessionContext } from './chat-agents';
|
||||
import {
|
||||
ChangeSetElement,
|
||||
ChangeSetImpl,
|
||||
ChatContext,
|
||||
ChatModel,
|
||||
ChatRequest,
|
||||
ChatRequestModel,
|
||||
ChatResponseModel,
|
||||
ErrorChatResponseModel,
|
||||
MutableChatModel,
|
||||
MutableChatRequestModel,
|
||||
} from './chat-model';
|
||||
import { ChatRequestParser } from './chat-request-parser';
|
||||
import { ChatSessionNamingService } from './chat-session-naming-service';
|
||||
import { ParsedChatRequest, ParsedChatRequestAgentPart } from './parsed-chat-request';
|
||||
import { ChatSessionIndex, ChatSessionStore } from './chat-session-store';
|
||||
import { ChatContentDeserializerRegistry } from './chat-content-deserializer';
|
||||
import { ChangeSetDeserializationContext, ChangeSetElementDeserializerRegistry } from './change-set-element-deserializer';
|
||||
import { SerializableChangeSetElement, SerializedChatModel, SerializableParsedRequest } from './chat-model-serialization';
|
||||
import debounce = require('@theia/core/shared/lodash.debounce');
|
||||
|
||||
export interface ChatRequestInvocation {
|
||||
/**
|
||||
* Promise which completes once the request preprocessing is complete.
|
||||
*/
|
||||
requestCompleted: Promise<ChatRequestModel>;
|
||||
/**
|
||||
* Promise which completes once a response is expected to arrive.
|
||||
*/
|
||||
responseCreated: Promise<ChatResponseModel>;
|
||||
/**
|
||||
* Promise which completes once the response is complete.
|
||||
*/
|
||||
responseCompleted: Promise<ChatResponseModel>;
|
||||
}
|
||||
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
title?: string;
|
||||
lastInteraction?: Date;
|
||||
model: ChatModel;
|
||||
isActive: boolean;
|
||||
pinnedAgent?: ChatAgent;
|
||||
}
|
||||
|
||||
export interface ActiveSessionChangedEvent {
|
||||
type: 'activeChange';
|
||||
sessionId: string | undefined;
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
export function isActiveSessionChangedEvent(obj: unknown): obj is ActiveSessionChangedEvent {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
return typeof obj === 'object' && obj !== null && 'type' in obj && obj.type === 'activeChange';
|
||||
}
|
||||
|
||||
export interface SessionCreatedEvent {
|
||||
type: 'created';
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function isSessionCreatedEvent(obj: unknown): obj is SessionCreatedEvent {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
return typeof obj === 'object' && obj !== null && 'type' in obj && obj.type === 'created';
|
||||
}
|
||||
|
||||
export interface SessionDeletedEvent {
|
||||
type: 'deleted';
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function isSessionDeletedEvent(obj: unknown): obj is SessionDeletedEvent {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
return typeof obj === 'object' && obj !== null && 'type' in obj && obj.type === 'deleted';
|
||||
}
|
||||
|
||||
export interface SessionOptions {
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default chat agent to invoke
|
||||
*/
|
||||
export const DefaultChatAgentId = Symbol('DefaultChatAgentId');
|
||||
export interface DefaultChatAgentId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In case no fitting chat agent is available, this one will be used (if it is itself available)
|
||||
*/
|
||||
export const FallbackChatAgentId = Symbol('FallbackChatAgentId');
|
||||
export interface FallbackChatAgentId {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const PinChatAgent = Symbol('PinChatAgent');
|
||||
export type PinChatAgent = boolean;
|
||||
|
||||
export const ChatService = Symbol('ChatService');
|
||||
export const ChatServiceFactory = Symbol('ChatServiceFactory');
|
||||
export interface ChatService {
|
||||
onSessionEvent: Event<ActiveSessionChangedEvent | SessionCreatedEvent | SessionDeletedEvent>
|
||||
|
||||
getSession(id: string): ChatSession | undefined;
|
||||
getSessions(): ChatSession[];
|
||||
createSession(location?: ChatAgentLocation, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession;
|
||||
deleteSession(sessionId: string): Promise<void>;
|
||||
getActiveSession(): ChatSession | undefined;
|
||||
setActiveSession(sessionId: string, options?: SessionOptions): void;
|
||||
|
||||
sendRequest(
|
||||
sessionId: string,
|
||||
request: ChatRequest
|
||||
): Promise<ChatRequestInvocation | undefined>;
|
||||
|
||||
deleteChangeSet(sessionId: string): void;
|
||||
deleteChangeSetElement(sessionId: string, uri: URI): void;
|
||||
|
||||
cancelRequest(sessionId: string, requestId: string): Promise<void>;
|
||||
|
||||
getAgent(parsedRequest: ParsedChatRequest, session: ChatSession): ChatAgent | undefined;
|
||||
|
||||
/**
|
||||
* Get an existing session or restore from storage
|
||||
*/
|
||||
getOrRestoreSession(sessionId: string): Promise<ChatSession | undefined>;
|
||||
/**
|
||||
* Get all persisted session metadata.
|
||||
* Note: This may trigger storage initialization if not already initialized.
|
||||
*/
|
||||
getPersistedSessions(): Promise<ChatSessionIndex>;
|
||||
/**
|
||||
* Check if there are persisted sessions available.
|
||||
*/
|
||||
hasPersistedSessions(): Promise<boolean>;
|
||||
}
|
||||
|
||||
interface ChatSessionInternal extends ChatSession {
|
||||
model: MutableChatModel;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChatServiceImpl implements ChatService {
|
||||
protected readonly onSessionEventEmitter = new Emitter<ActiveSessionChangedEvent | SessionCreatedEvent | SessionDeletedEvent>();
|
||||
onSessionEvent = this.onSessionEventEmitter.event;
|
||||
|
||||
@inject(ChatAgentService)
|
||||
protected chatAgentService: ChatAgentService;
|
||||
|
||||
@inject(DefaultChatAgentId) @optional()
|
||||
protected defaultChatAgentId: DefaultChatAgentId | undefined;
|
||||
|
||||
@inject(FallbackChatAgentId) @optional()
|
||||
protected fallbackChatAgentId: FallbackChatAgentId | undefined;
|
||||
|
||||
@inject(ChatSessionNamingService) @optional()
|
||||
protected chatSessionNamingService: ChatSessionNamingService | undefined;
|
||||
|
||||
@inject(PinChatAgent) @optional()
|
||||
protected pinChatAgent: boolean | undefined;
|
||||
|
||||
@inject(ChatRequestParser)
|
||||
protected chatRequestParser: ChatRequestParser;
|
||||
|
||||
@inject(AIVariableService)
|
||||
protected variableService: AIVariableService;
|
||||
|
||||
@inject(ILogger)
|
||||
protected logger: ILogger;
|
||||
|
||||
@inject(ChatSessionStore) @optional()
|
||||
protected sessionStore: ChatSessionStore | undefined;
|
||||
|
||||
@inject(ChatContentDeserializerRegistry)
|
||||
protected deserializerRegistry: ChatContentDeserializerRegistry;
|
||||
|
||||
@inject(ChangeSetElementDeserializerRegistry)
|
||||
protected changeSetElementDeserializerRegistry: ChangeSetElementDeserializerRegistry;
|
||||
|
||||
@inject(ToolInvocationRegistry)
|
||||
protected toolInvocationRegistry: ToolInvocationRegistry;
|
||||
|
||||
protected _sessions: ChatSessionInternal[] = [];
|
||||
|
||||
getSessions(): ChatSessionInternal[] {
|
||||
return [...this._sessions];
|
||||
}
|
||||
|
||||
getSession(id: string): ChatSessionInternal | undefined {
|
||||
return this._sessions.find(session => session.id === id);
|
||||
}
|
||||
|
||||
createSession(location = ChatAgentLocation.Panel, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession {
|
||||
const model = new MutableChatModel(location);
|
||||
const session: ChatSessionInternal = {
|
||||
id: model.id,
|
||||
lastInteraction: new Date(),
|
||||
model,
|
||||
isActive: true,
|
||||
pinnedAgent
|
||||
};
|
||||
this._sessions.push(session);
|
||||
this.setupAutoSaveForSession(session);
|
||||
this.setActiveSession(session.id, options);
|
||||
this.onSessionEventEmitter.fire({ type: 'created', sessionId: session.id });
|
||||
return session;
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
const sessionIndex = this._sessions.findIndex(candidate => candidate.id === sessionId);
|
||||
|
||||
// If session is in memory, remove it
|
||||
if (sessionIndex !== -1) {
|
||||
const session = this._sessions[sessionIndex];
|
||||
// If the removed session is the active one, set the newest one as active
|
||||
if (session.isActive) {
|
||||
this.setActiveSession(this._sessions[this._sessions.length - 1]?.id);
|
||||
}
|
||||
session.model.dispose();
|
||||
this._sessions.splice(sessionIndex, 1);
|
||||
this.onSessionEventEmitter.fire({ type: 'deleted', sessionId: sessionId });
|
||||
}
|
||||
|
||||
// Always delete from persistent storage
|
||||
if (this.sessionStore) {
|
||||
try {
|
||||
await this.sessionStore.deleteSession(sessionId);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to delete session from storage', { sessionId, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getActiveSession(): ChatSession | undefined {
|
||||
const activeSessions = this._sessions.filter(candidate => candidate.isActive);
|
||||
if (activeSessions.length > 1) { throw new Error('More than one session marked as active. This indicates an error in ChatService.'); }
|
||||
return activeSessions.at(0);
|
||||
}
|
||||
|
||||
setActiveSession(sessionId: string | undefined, options?: SessionOptions): void {
|
||||
this._sessions.forEach(session => {
|
||||
session.isActive = session.id === sessionId;
|
||||
});
|
||||
this.onSessionEventEmitter.fire({ type: 'activeChange', sessionId: sessionId, ...options });
|
||||
}
|
||||
|
||||
async sendRequest(
|
||||
sessionId: string,
|
||||
request: ChatRequest,
|
||||
): Promise<ChatRequestInvocation | undefined> {
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.cancelIncompleteRequests(session);
|
||||
|
||||
const resolutionContext: ChatSessionContext = { model: session.model };
|
||||
const resolvedContext = await this.resolveChatContext(request.variables ?? session.model.context.getVariables(), resolutionContext);
|
||||
const parsedRequest = await this.chatRequestParser.parseChatRequest(request, session.model.location, resolvedContext);
|
||||
const agent = this.getAgent(parsedRequest, session);
|
||||
session.pinnedAgent = agent;
|
||||
|
||||
if (agent === undefined) {
|
||||
const error = 'No agent was found to handle this request. ' +
|
||||
'Please ensure you have configured a default agent in the preferences and that the agent is enabled in the AI Configuration view. ' +
|
||||
'Alternatively, mention a specific agent with @AgentName.';
|
||||
this.logger.error(error);
|
||||
const chatResponseModel = new ErrorChatResponseModel(generateUuid(), new Error(error));
|
||||
return {
|
||||
requestCompleted: Promise.reject(error),
|
||||
responseCreated: Promise.reject(error),
|
||||
responseCompleted: Promise.resolve(chatResponseModel),
|
||||
};
|
||||
}
|
||||
|
||||
const requestModel = session.model.addRequest(parsedRequest, agent?.id, resolvedContext);
|
||||
this.updateSessionMetadata(session, requestModel);
|
||||
resolutionContext.request = requestModel;
|
||||
|
||||
const responseCompletionDeferred = new Deferred<ChatResponseModel>();
|
||||
const invocation: ChatRequestInvocation = {
|
||||
requestCompleted: Promise.resolve(requestModel),
|
||||
responseCreated: Promise.resolve(requestModel.response),
|
||||
responseCompleted: responseCompletionDeferred.promise,
|
||||
};
|
||||
|
||||
requestModel.response.onDidChange(() => {
|
||||
if (requestModel.response.isComplete) {
|
||||
responseCompletionDeferred.resolve(requestModel.response);
|
||||
}
|
||||
if (requestModel.response.isError) {
|
||||
responseCompletionDeferred.resolve(requestModel.response);
|
||||
}
|
||||
});
|
||||
|
||||
agent.invoke(requestModel).catch(error => requestModel.response.error(error));
|
||||
|
||||
return invocation;
|
||||
}
|
||||
|
||||
protected cancelIncompleteRequests(session: ChatSessionInternal): void {
|
||||
for (const pastRequest of session.model.getRequests()) {
|
||||
if (!pastRequest.response.isComplete) {
|
||||
pastRequest.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected updateSessionMetadata(session: ChatSessionInternal, request: MutableChatRequestModel): void {
|
||||
session.lastInteraction = new Date();
|
||||
if (session.title) {
|
||||
return;
|
||||
}
|
||||
const requestText = request.request.displayText ?? request.request.text;
|
||||
session.title = requestText;
|
||||
if (this.chatSessionNamingService) {
|
||||
const otherSessionNames = this._sessions.map(s => s.title).filter((title): title is string => title !== undefined);
|
||||
const namingService = this.chatSessionNamingService;
|
||||
let didGenerateName = false;
|
||||
request.response.onDidChange(() => {
|
||||
if (request.response.isComplete && !didGenerateName) {
|
||||
namingService.generateChatSessionName(session, otherSessionNames).then(name => {
|
||||
if (name && session.title === requestText) {
|
||||
session.title = name;
|
||||
// Trigger persistence when title changes
|
||||
this.saveSession(session.id);
|
||||
}
|
||||
didGenerateName = true;
|
||||
}).catch(error => this.logger.error('Failed to generate chat session name', error));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async resolveChatContext(
|
||||
resolutionRequests: readonly AIVariableResolutionRequest[],
|
||||
context: ChatSessionContext,
|
||||
): Promise<ChatContext> {
|
||||
// TODO use a common cache to resolve variables and return recursively resolved variables?
|
||||
const resolvedVariables = await Promise.all(resolutionRequests.map(async contextVariable => this.variableService.resolveVariable(contextVariable, context)))
|
||||
.then(results => results.filter(ResolvedAIContextVariable.is));
|
||||
return { variables: resolvedVariables };
|
||||
}
|
||||
|
||||
async cancelRequest(sessionId: string, requestId: string): Promise<void> {
|
||||
return this.getSession(sessionId)?.model.getRequest(requestId)?.response.cancel();
|
||||
}
|
||||
|
||||
getAgent(parsedRequest: ParsedChatRequest, session: ChatSession): ChatAgent | undefined {
|
||||
const agent = this.initialAgentSelection(parsedRequest);
|
||||
if (!this.isPinChatAgentEnabled()) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
return this.getPinnedAgent(parsedRequest, session, agent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if chat agent pinning is enabled.
|
||||
* Can be overridden by subclasses to provide different logic (e.g., using preferences).
|
||||
*/
|
||||
protected isPinChatAgentEnabled(): boolean {
|
||||
return this.pinChatAgent !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an agent is pinned, and use it if no other agent is mentioned.
|
||||
*/
|
||||
protected getPinnedAgent(parsedRequest: ParsedChatRequest, session: ChatSession, agent: ChatAgent | undefined): ChatAgent | undefined {
|
||||
const mentionedAgentPart = this.getMentionedAgent(parsedRequest);
|
||||
const mentionedAgent = mentionedAgentPart ? this.chatAgentService.getAgent(mentionedAgentPart.agentId) : undefined;
|
||||
if (mentionedAgent) {
|
||||
return mentionedAgent;
|
||||
} else if (session.pinnedAgent) {
|
||||
// If we have a valid pinned agent, use it (pinned agent may become stale
|
||||
// if it was disabled; so we always need to recheck)
|
||||
const pinnedAgent = this.chatAgentService.getAgent(session.pinnedAgent.id);
|
||||
if (pinnedAgent) {
|
||||
return pinnedAgent;
|
||||
}
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
protected initialAgentSelection(parsedRequest: ParsedChatRequest): ChatAgent | undefined {
|
||||
const agentPart = this.getMentionedAgent(parsedRequest);
|
||||
if (agentPart) {
|
||||
return this.chatAgentService.getAgent(agentPart.agentId);
|
||||
}
|
||||
if (this.defaultChatAgentId) {
|
||||
return this.chatAgentService.getAgent(this.defaultChatAgentId.id);
|
||||
}
|
||||
if (this.fallbackChatAgentId) {
|
||||
return this.chatAgentService.getAgent(this.fallbackChatAgentId.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected getMentionedAgent(parsedRequest: ParsedChatRequest): ParsedChatRequestAgentPart | undefined {
|
||||
return parsedRequest.parts.find(p => p instanceof ParsedChatRequestAgentPart) as ParsedChatRequestAgentPart | undefined;
|
||||
}
|
||||
|
||||
deleteChangeSet(sessionId: string): void {
|
||||
const model = this.getSession(sessionId)?.model;
|
||||
model?.changeSet.setElements();
|
||||
}
|
||||
|
||||
deleteChangeSetElement(sessionId: string, uri: URI): void {
|
||||
this.getSession(sessionId)?.model.changeSet.removeElements(uri);
|
||||
}
|
||||
|
||||
protected saveSession(sessionId: string): void {
|
||||
if (!this.sessionStore) {
|
||||
this.logger.debug('Session store not available, skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.getSession(sessionId);
|
||||
if (!session) {
|
||||
this.logger.debug('Session not found, skipping save', { sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't save empty sessions
|
||||
if (session.model.isEmpty()) {
|
||||
this.logger.debug('Session is empty, skipping save', { sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Store session with title and pinned agent info
|
||||
this.sessionStore.storeSessions(
|
||||
{ model: session.model, title: session.title, pinnedAgentId: session.pinnedAgent?.id }
|
||||
).catch(error => {
|
||||
this.logger.error('Failed to store chat sessions', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up auto-save for a session by listening to model changes.
|
||||
*/
|
||||
protected setupAutoSaveForSession(session: ChatSessionInternal): void {
|
||||
const debouncedSave = debounce(() => this.saveSession(session.id), 500, { maxWait: 5000 });
|
||||
session.model.onDidChange(_event => {
|
||||
debouncedSave();
|
||||
});
|
||||
}
|
||||
|
||||
async getOrRestoreSession(sessionId: string): Promise<ChatSession | undefined> {
|
||||
const existing = this.getSession(sessionId);
|
||||
if (existing) {
|
||||
this.logger.debug('Session already loaded', { sessionId });
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!this.sessionStore) {
|
||||
this.logger.debug('Session store not available, cannot restore', { sessionId });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger.debug('Restoring session from storage', { sessionId });
|
||||
|
||||
const serialized = await this.sessionStore.readSession(sessionId);
|
||||
if (!serialized) {
|
||||
this.logger.warn('Session not found in storage', { sessionId });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.logger.debug('Session loaded from storage', {
|
||||
sessionId,
|
||||
requestCount: serialized.model.requests.length,
|
||||
responseCount: serialized.model.responses.length,
|
||||
version: serialized.version
|
||||
});
|
||||
|
||||
const model = new MutableChatModel(serialized.model);
|
||||
await this.restoreSessionData(model, serialized.model);
|
||||
|
||||
// Determine pinned agent
|
||||
const pinnedAgent = serialized.pinnedAgentId
|
||||
? this.chatAgentService.getAgent(serialized.pinnedAgentId)
|
||||
: undefined;
|
||||
|
||||
// Register as session
|
||||
const session: ChatSessionInternal = {
|
||||
id: sessionId,
|
||||
title: serialized.title,
|
||||
lastInteraction: new Date(serialized.saveDate),
|
||||
model,
|
||||
isActive: false,
|
||||
pinnedAgent
|
||||
};
|
||||
this._sessions.push(session);
|
||||
this.setupAutoSaveForSession(session);
|
||||
this.onSessionEventEmitter.fire({ type: 'created', sessionId: session.id });
|
||||
|
||||
this.logger.debug('Session successfully restored and registered', { sessionId, title: session.title });
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async getPersistedSessions(): Promise<ChatSessionIndex> {
|
||||
if (!this.sessionStore) {
|
||||
return {};
|
||||
}
|
||||
return this.sessionStore.getSessionIndex();
|
||||
}
|
||||
|
||||
async hasPersistedSessions(): Promise<boolean> {
|
||||
if (!this.sessionStore) {
|
||||
return false;
|
||||
}
|
||||
return this.sessionStore.hasPersistedSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize response content and restore changesets.
|
||||
* Called after basic chat model structure was created.
|
||||
*/
|
||||
protected async restoreSessionData(model: MutableChatModel, data: SerializedChatModel): Promise<void> {
|
||||
this.logger.debug('Restoring dynamic session data', { sessionId: data.sessionId, requestCount: data.requests.length });
|
||||
|
||||
// Process each request for response content and changeset restoration
|
||||
// IMPORTANT: Use getAllRequests() to include alternatives, not just active requests
|
||||
const requests = model.getAllRequests();
|
||||
for (let i = 0; i < requests.length; i++) {
|
||||
const requestModel = requests[i];
|
||||
|
||||
this.logger.debug('Restore request content', { requestId: requestModel.id, index: i });
|
||||
const reqData = data.requests.find(r => r.id === requestModel.id);
|
||||
if (reqData?.parsedRequest) {
|
||||
const toolRequests = this.restoreToolRequests(reqData.parsedRequest);
|
||||
requestModel.restoreToolRequests(toolRequests);
|
||||
this.logger.debug('Restored tool requests', { requestId: requestModel.id, toolRequests: Array.from(toolRequests.keys()) });
|
||||
}
|
||||
|
||||
this.logger.debug('Restore response content', { requestId: requestModel.id, index: i });
|
||||
|
||||
// Restore response content using deserializer registry
|
||||
const respData = data.responses.find(r => r.requestId === requestModel.id);
|
||||
if (respData && respData.content.length > 0) {
|
||||
const restoredContent = await Promise.all(respData.content.map(contentData =>
|
||||
this.deserializerRegistry.deserialize(contentData)
|
||||
));
|
||||
restoredContent.forEach(content => requestModel.response.response.addContent(content));
|
||||
this.logger.debug('Restored response content', {
|
||||
requestId: requestModel.id,
|
||||
contentCount: restoredContent.length
|
||||
});
|
||||
}
|
||||
|
||||
// Restore changeset elements
|
||||
const serializedChangeSet = data.requests.find(r => r.id === requestModel.id)?.changeSet;
|
||||
if (serializedChangeSet && serializedChangeSet.elements && serializedChangeSet.elements.length > 0) {
|
||||
// Create a changeset if one doesn't exist
|
||||
if (!requestModel.changeSet) {
|
||||
requestModel.changeSet = new ChangeSetImpl();
|
||||
}
|
||||
await this.restoreChangeSetElements(requestModel, serializedChangeSet.elements, data.sessionId);
|
||||
|
||||
// Restore changeset title
|
||||
if (serializedChangeSet.title) {
|
||||
requestModel.changeSet.setTitle(serializedChangeSet.title);
|
||||
}
|
||||
|
||||
this.logger.debug('Restored changeset', {
|
||||
requestId: requestModel.id,
|
||||
elementCount: serializedChangeSet.elements.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Restoring dynamic session data complete', { sessionId: data.sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and resolves tool requests from serialized data.
|
||||
* Looks up actual ToolRequest objects from the registry, or creates fallbacks if not found.
|
||||
*/
|
||||
protected restoreToolRequests(data: SerializableParsedRequest): Map<string, ToolRequest> {
|
||||
const toolRequests = new Map<string, ToolRequest>();
|
||||
for (const toolData of data.toolRequests) {
|
||||
toolRequests.set(toolData.id, this.loadToolRequestOrFallback(toolData.id));
|
||||
}
|
||||
return toolRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a tool request from the registry or creates a fallback if not found.
|
||||
*/
|
||||
protected loadToolRequestOrFallback(toolId: string): ToolRequest {
|
||||
const actualTool = this.toolInvocationRegistry.getFunction(toolId);
|
||||
if (actualTool) {
|
||||
return actualTool;
|
||||
}
|
||||
this.logger.warn(`Could not restore tool request with id '${toolId}' because it was not found in the registry.`);
|
||||
return {
|
||||
id: toolId,
|
||||
name: toolId,
|
||||
parameters: { type: 'object' as const, properties: {} },
|
||||
handler: async () => {
|
||||
throw new Error('Tool request handler not available because tool could not be found.');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected async restoreChangeSetElements(
|
||||
requestModel: MutableChatRequestModel,
|
||||
elements: SerializableChangeSetElement[],
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
this.logger.debug('Restoring changeset elements', { requestId: requestModel.id, elementCount: elements.length });
|
||||
|
||||
const context: ChangeSetDeserializationContext = {
|
||||
chatSessionId: sessionId,
|
||||
requestId: requestModel.id
|
||||
};
|
||||
|
||||
const restoredElements: ChangeSetElement[] = [];
|
||||
|
||||
for (const elem of elements) {
|
||||
const restoredElement = await this.changeSetElementDeserializerRegistry.deserialize(elem, context);
|
||||
restoredElements.push(restoredElement);
|
||||
}
|
||||
|
||||
// Add elements to the request's changeset
|
||||
if (requestModel.changeSet) {
|
||||
requestModel.changeSet.addElements(...restoredElements);
|
||||
this.logger.debug('Changeset elements restored', { requestId: requestModel.id, elementCount: restoredElements.length });
|
||||
} else {
|
||||
this.logger.warn('Request has no changeset, cannot restore elements', { requestId: requestModel.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable @typescript-eslint/tslint/config */
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This file is licensed under the MIT License.
|
||||
// See LICENSE-MIT.txt in the project root for license information.
|
||||
// https://opensource.org/license/mit.
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
// *****************************************************************************
|
||||
|
||||
import { PromptVariantSet } from '@theia/ai-core';
|
||||
|
||||
export const CHAT_SESSION_NAMING_PROMPT: PromptVariantSet = {
|
||||
id: 'chat-session-naming-system',
|
||||
defaultVariant: {
|
||||
id: 'chat-session-naming-system-default',
|
||||
template: '{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit).\n' +
|
||||
'Made improvements or adaptations to this prompt template? We\'d love for you to share it with the community! Contribute back here: ' +
|
||||
'https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}}\n\n' +
|
||||
'Provide a short and descriptive name for the given AI chat conversation of an AI-powered tool based on the conversation below.\n\n' +
|
||||
'The purpose of the name is for users to recognize the chat conversation easily in a list of conversations. ' +
|
||||
'Use the same language for the chat conversation name as used in the provided conversation, if in doubt default to English. ' +
|
||||
'Start the chat conversation name with an upper-case letter. ' +
|
||||
'Below we also provide the already existing other conversation names, make sure your suggestion for a name is unique with respect to the existing ones.\n\n' +
|
||||
'IMPORTANT: Your answer MUST ONLY CONTAIN THE PROPOSED NAME and must not be preceded or followed by any other text.' +
|
||||
'\n\nOther session names:\n{{listOfSessionNames}}' +
|
||||
'\n\nConversation:\n{{conversation}}',
|
||||
}
|
||||
};
|
||||
122
packages/ai-chat/src/common/chat-session-naming-service.ts
Normal file
122
packages/ai-chat/src/common/chat-session-naming-service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
Agent,
|
||||
AgentService,
|
||||
getTextOfResponse,
|
||||
LanguageModelRegistry,
|
||||
LanguageModelRequirement,
|
||||
LanguageModelService,
|
||||
PromptService,
|
||||
UserRequest
|
||||
} from '@theia/ai-core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatSession } from './chat-service';
|
||||
import { generateUuid, nls } from '@theia/core';
|
||||
|
||||
import { CHAT_SESSION_NAMING_PROMPT } from './chat-session-naming-prompt-template';
|
||||
|
||||
@injectable()
|
||||
export class ChatSessionNamingService {
|
||||
@inject(AgentService) protected agentService: AgentService;
|
||||
async generateChatSessionName(chatSession: ChatSession, otherNames: string[]): Promise<string | undefined> {
|
||||
const chatSessionNamingAgent = this.agentService.getAgents().find(agent => ChatSessionNamingAgent.ID === agent.id);
|
||||
if (!(chatSessionNamingAgent instanceof ChatSessionNamingAgent)) {
|
||||
return undefined;
|
||||
}
|
||||
return chatSessionNamingAgent.generateChatSessionName(chatSession, otherNames);
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChatSessionNamingAgent implements Agent {
|
||||
static ID = 'Chat Session Naming';
|
||||
id = ChatSessionNamingAgent.ID;
|
||||
name = ChatSessionNamingAgent.ID;
|
||||
description = nls.localize('theia/ai/chat/chatSessionNamingAgent/description', 'Agent for generating chat session names');
|
||||
variables = [];
|
||||
prompts = [CHAT_SESSION_NAMING_PROMPT];
|
||||
languageModelRequirements: LanguageModelRequirement[] = [{
|
||||
purpose: 'chat-session-naming',
|
||||
identifier: 'default/summarize',
|
||||
}];
|
||||
agentSpecificVariables = [
|
||||
{
|
||||
name: 'conversation',
|
||||
usedInPrompt: true,
|
||||
description: nls.localize('theia/ai/chat/chatSessionNamingAgent/vars/conversation/description', 'The content of the chat conversation.')
|
||||
},
|
||||
{
|
||||
name: 'listOfSessionNames',
|
||||
usedInPrompt: true,
|
||||
description: nls.localize('theia/ai/chat/chatSessionNamingAgent/vars/listOfSessionNames/description', 'The list of existing session names.')
|
||||
}
|
||||
];
|
||||
functions = [];
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
protected readonly lmRegistry: LanguageModelRegistry;
|
||||
|
||||
@inject(LanguageModelService)
|
||||
protected readonly languageModelService: LanguageModelService;
|
||||
|
||||
@inject(PromptService)
|
||||
protected promptService: PromptService;
|
||||
|
||||
async generateChatSessionName(chatSession: ChatSession, otherNames: string[]): Promise<string> {
|
||||
const lm = await this.lmRegistry.selectLanguageModel({ agent: this.id, ...this.languageModelRequirements[0] });
|
||||
if (!lm) {
|
||||
throw new Error('No language model found for chat session naming');
|
||||
}
|
||||
if (chatSession.model.getRequests().length < 1) {
|
||||
throw new Error('No chat request available to generate chat session name');
|
||||
}
|
||||
|
||||
const conversation = chatSession.model.getRequests()
|
||||
.map(req => `<user>${req.message.parts.map(chunk => chunk.promptText).join('')}</user>` +
|
||||
(req.response.response ? `<assistant>${req.response.response.asString()}</assistant>` : ''))
|
||||
.join('\n\n');
|
||||
const listOfSessionNames = otherNames.map(name => name).join(', ');
|
||||
|
||||
const prompt = await this.promptService.getResolvedPromptFragment(CHAT_SESSION_NAMING_PROMPT.id, { conversation, listOfSessionNames });
|
||||
const message = prompt?.text;
|
||||
if (!message) {
|
||||
throw new Error('Unable to create prompt message for generating chat session name');
|
||||
}
|
||||
|
||||
const variantInfo = this.promptService.getPromptVariantInfo(CHAT_SESSION_NAMING_PROMPT.id);
|
||||
|
||||
const sessionId = generateUuid();
|
||||
const requestId = generateUuid();
|
||||
const request: UserRequest & { agentId: string } = {
|
||||
messages: [{
|
||||
actor: 'user',
|
||||
text: message,
|
||||
type: 'text'
|
||||
}],
|
||||
requestId,
|
||||
sessionId,
|
||||
agentId: this.id,
|
||||
promptVariantId: variantInfo?.variantId,
|
||||
isPromptVariantCustomized: variantInfo?.isCustomized
|
||||
};
|
||||
const result = await this.languageModelService.sendRequest(lm, request);
|
||||
const response = await getTextOfResponse(result);
|
||||
|
||||
return response.replace(/\s+/g, ' ').substring(0, 100);
|
||||
}
|
||||
}
|
||||
70
packages/ai-chat/src/common/chat-session-store.ts
Normal file
70
packages/ai-chat/src/common/chat-session-store.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ChatModel } from './chat-model';
|
||||
import { SerializedChatData } from './chat-model-serialization';
|
||||
import { ChatAgentLocation } from './chat-agents';
|
||||
|
||||
export const ChatSessionStore = Symbol('ChatSessionStore');
|
||||
|
||||
export interface ChatModelWithMetadata {
|
||||
model: ChatModel;
|
||||
title?: string;
|
||||
pinnedAgentId?: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionStore {
|
||||
/**
|
||||
* Stores the handed over sessions.
|
||||
*
|
||||
* Might overwrite existing sessions when maximum storage capacity is exceeded.
|
||||
*/
|
||||
storeSessions(...sessions: Array<ChatModel | ChatModelWithMetadata>): Promise<void>;
|
||||
/**
|
||||
* Read specified session
|
||||
*/
|
||||
readSession(sessionId: string): Promise<SerializedChatData | undefined>;
|
||||
/**
|
||||
* Delete specified session
|
||||
*/
|
||||
deleteSession(sessionId: string): Promise<void>;
|
||||
/**
|
||||
* Deletes all sessions
|
||||
*/
|
||||
clearAllSessions(): Promise<void>;
|
||||
/**
|
||||
* Get index of all stored sessions.
|
||||
* Note: This may trigger storage initialization if not already initialized.
|
||||
*/
|
||||
getSessionIndex(): Promise<ChatSessionIndex>;
|
||||
/**
|
||||
* Check if there are persisted sessions available
|
||||
* This has the benefit of not initializing the storage on disk if it does not
|
||||
* already exist.
|
||||
*/
|
||||
hasPersistedSessions(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ChatSessionIndex {
|
||||
[sessionId: string]: ChatSessionMetadata;
|
||||
}
|
||||
|
||||
export interface ChatSessionMetadata {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
saveDate: number;
|
||||
location: ChatAgentLocation;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* eslint-disable @typescript-eslint/tslint/config */
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This file is licensed under the MIT License.
|
||||
// See LICENSE-MIT.txt in the project root for license information.
|
||||
// https://opensource.org/license/mit.
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { CHANGE_SET_SUMMARY_VARIABLE_ID } from './context-variables';
|
||||
|
||||
export const CHAT_SESSION_SUMMARY_PROMPT = {
|
||||
id: 'chat-session-summary-system',
|
||||
defaultVariant: {
|
||||
id: 'chat-session-summary-system-default',
|
||||
template: '{{!-- !-- This prompt is licensed under the MIT License (https://opensource.org/license/mit).\n' +
|
||||
'Made improvements or adaptations to this prompt template? We\'d love for you to share it with the community! Contribute back here: ' +
|
||||
'https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}}\n\n' +
|
||||
'You are a chat agent for summarizing AI agent chat sessions for later use. ' +
|
||||
'Review the conversation above and generate a concise summary that captures every crucial detail, ' +
|
||||
'including all requirements, decisions, and pending tasks. ' +
|
||||
'Ensure that the summary is sufficiently comprehensive to allow seamless continuation of the workflow. ' +
|
||||
'The summary will primarily be used by other AI agents, so tailor your response for use by AI agents. ' +
|
||||
'Also consider the system message. ' +
|
||||
'Make sure you include all necessary context information and use unique references (such as URIs, file paths, etc.). ' +
|
||||
'If the conversation was about a task, describe the state of the task, i.e. what has been completed and what is open. ' +
|
||||
'If a changeset is open in the session, describe the state of the suggested changes. ' +
|
||||
`\n\n{{${CHANGE_SET_SUMMARY_VARIABLE_ID}}}`,
|
||||
}
|
||||
};
|
||||
42
packages/ai-chat/src/common/chat-session-summary-agent.ts
Normal file
42
packages/ai-chat/src/common/chat-session-summary-agent.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
LanguageModelRequirement,
|
||||
PromptVariantSet
|
||||
} from '@theia/ai-core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { AbstractStreamParsingChatAgent, ChatAgent } from './chat-agents';
|
||||
import { CHAT_SESSION_SUMMARY_PROMPT } from './chat-session-summary-agent-prompt';
|
||||
import { nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class ChatSessionSummaryAgent extends AbstractStreamParsingChatAgent implements ChatAgent {
|
||||
static ID = 'chat-session-summary-agent';
|
||||
id = ChatSessionSummaryAgent.ID;
|
||||
name = 'Chat Session Summary';
|
||||
override description = nls.localize('theia/ai/chat/chatSessionSummaryAgent/description', 'Agent for generating chat session summaries.');
|
||||
override prompts: PromptVariantSet[] = [CHAT_SESSION_SUMMARY_PROMPT];
|
||||
protected readonly defaultLanguageModelPurpose = 'chat-session-summary';
|
||||
languageModelRequirements: LanguageModelRequirement[] = [{
|
||||
purpose: 'chat-session-summary',
|
||||
identifier: 'default/summarize',
|
||||
}];
|
||||
override agentSpecificVariables = [];
|
||||
override functions = [];
|
||||
override locations = [];
|
||||
override tags = [];
|
||||
}
|
||||
23
packages/ai-chat/src/common/chat-string-utils.ts
Normal file
23
packages/ai-chat/src/common/chat-string-utils.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export function stringJsonCodeBlock(input: string): string {
|
||||
return `\`\`\`json\n${input}\n\`\`\``;
|
||||
}
|
||||
|
||||
export function dataToJsonCodeBlock(input: unknown): string {
|
||||
return stringJsonCodeBlock(JSON.stringify(input, undefined, 2));
|
||||
}
|
||||
82
packages/ai-chat/src/common/chat-tool-preferences.ts
Normal file
82
packages/ai-chat/src/common/chat-tool-preferences.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences';
|
||||
import { nls } from '@theia/core';
|
||||
import {
|
||||
createPreferenceProxy,
|
||||
PreferenceContribution,
|
||||
PreferenceProxy,
|
||||
PreferenceSchema,
|
||||
PreferenceService,
|
||||
} from '@theia/core/lib/common/preferences';
|
||||
import { interfaces } from '@theia/core/shared/inversify';
|
||||
|
||||
export type ChatToolPreferences = PreferenceProxy<ChatToolConfiguration>;
|
||||
|
||||
export const ChatToolPreferenceContribution = Symbol('ChatToolPreferenceContribution');
|
||||
export const ChatToolPreferences = Symbol('ChatToolPreferences');
|
||||
|
||||
export function createChatToolPreferences(preferences: PreferenceService, schema: PreferenceSchema = chatToolPreferences): ChatToolPreferences {
|
||||
return createPreferenceProxy(preferences, schema);
|
||||
}
|
||||
|
||||
export function bindChatToolPreferences(bind: interfaces.Bind): void {
|
||||
bind(ChatToolPreferences).toDynamicValue((ctx: interfaces.Context) => {
|
||||
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
|
||||
const contribution = ctx.container.get<PreferenceContribution>(ChatToolPreferenceContribution);
|
||||
return createChatToolPreferences(preferences, contribution.schema);
|
||||
}).inSingletonScope();
|
||||
bind(ChatToolPreferenceContribution).toConstantValue({ schema: chatToolPreferences });
|
||||
bind(PreferenceContribution).toService(ChatToolPreferenceContribution);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for tool confirmation modes
|
||||
*/
|
||||
export enum ToolConfirmationMode {
|
||||
ALWAYS_ALLOW = 'always_allow',
|
||||
CONFIRM = 'confirm',
|
||||
DISABLED = 'disabled'
|
||||
}
|
||||
|
||||
export const TOOL_CONFIRMATION_PREFERENCE = 'ai-features.chat.toolConfirmation';
|
||||
|
||||
export const chatToolPreferences: PreferenceSchema = {
|
||||
properties: {
|
||||
[TOOL_CONFIRMATION_PREFERENCE]: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
enum: [ToolConfirmationMode.ALWAYS_ALLOW, ToolConfirmationMode.CONFIRM, ToolConfirmationMode.DISABLED],
|
||||
enumDescriptions: [
|
||||
nls.localize('theia/ai/chat/toolConfirmation/yolo/description', 'Execute tools automatically without confirmation'),
|
||||
nls.localize('theia/ai/chat/toolConfirmation/confirm/description', 'Ask for confirmation before executing tools'),
|
||||
nls.localize('theia/ai/chat/toolConfirmation/disabled/description', 'Disable tool execution')
|
||||
]
|
||||
},
|
||||
default: {},
|
||||
description: nls.localize('theia/ai/chat/toolConfirmation/description',
|
||||
'Configure confirmation behavior for different tools. Key is the tool ID, value is the confirmation mode. ' +
|
||||
'Use "*" as the key to set a global default for all tools.'),
|
||||
title: AI_CORE_PREFERENCES_TITLE,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface ChatToolConfiguration {
|
||||
[TOOL_CONFIRMATION_PREFERENCE]: { [toolId: string]: ToolConfirmationMode };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { isEmptyToolArgs, normalizeToolArgs } from './chat-tool-request-service';
|
||||
|
||||
describe('Tool Arguments Utilities', () => {
|
||||
|
||||
describe('isEmptyToolArgs', () => {
|
||||
it('should return true for undefined', () => {
|
||||
expect(isEmptyToolArgs(undefined)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true for empty string', () => {
|
||||
expect(isEmptyToolArgs('')).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true for empty JSON object', () => {
|
||||
expect(isEmptyToolArgs('{}')).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true for empty JSON object with whitespace', () => {
|
||||
expect(isEmptyToolArgs('{ }')).to.be.true;
|
||||
expect(isEmptyToolArgs('{ }')).to.be.true;
|
||||
expect(isEmptyToolArgs('{\n}')).to.be.true;
|
||||
expect(isEmptyToolArgs('{ \n }')).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false for non-empty JSON object', () => {
|
||||
expect(isEmptyToolArgs('{"key": "value"}')).to.be.false;
|
||||
expect(isEmptyToolArgs('{"file": "test.ts"}')).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false for JSON array', () => {
|
||||
expect(isEmptyToolArgs('[]')).to.be.false;
|
||||
expect(isEmptyToolArgs('[1, 2, 3]')).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false for invalid JSON', () => {
|
||||
expect(isEmptyToolArgs('not json')).to.be.false;
|
||||
expect(isEmptyToolArgs('{')).to.be.false;
|
||||
expect(isEmptyToolArgs('{"truncated')).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false for JSON primitives', () => {
|
||||
expect(isEmptyToolArgs('null')).to.be.false;
|
||||
expect(isEmptyToolArgs('true')).to.be.false;
|
||||
expect(isEmptyToolArgs('42')).to.be.false;
|
||||
expect(isEmptyToolArgs('"string"')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeToolArgs', () => {
|
||||
it('should normalize undefined to empty string', () => {
|
||||
expect(normalizeToolArgs(undefined)).to.equal('');
|
||||
});
|
||||
|
||||
it('should normalize empty string to empty string', () => {
|
||||
expect(normalizeToolArgs('')).to.equal('');
|
||||
});
|
||||
|
||||
it('should normalize empty JSON object to empty string', () => {
|
||||
expect(normalizeToolArgs('{}')).to.equal('');
|
||||
expect(normalizeToolArgs('{ }')).to.equal('');
|
||||
});
|
||||
|
||||
it('should preserve non-empty JSON arguments', () => {
|
||||
const args = '{"file": "test.ts"}';
|
||||
expect(normalizeToolArgs(args)).to.equal(args);
|
||||
});
|
||||
|
||||
it('should preserve invalid JSON as-is', () => {
|
||||
const args = 'not json';
|
||||
expect(normalizeToolArgs(args)).to.equal(args);
|
||||
});
|
||||
|
||||
it('should allow matching empty arguments from different representations', () => {
|
||||
const fromStream = '{}';
|
||||
const fromHandler = '';
|
||||
|
||||
expect(normalizeToolArgs(fromStream)).to.equal(normalizeToolArgs(fromHandler));
|
||||
});
|
||||
});
|
||||
});
|
||||
144
packages/ai-chat/src/common/chat-tool-request-service.ts
Normal file
144
packages/ai-chat/src/common/chat-tool-request-service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ToolInvocationContext, ToolRequest } from '@theia/ai-core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { MutableChatRequestModel, MutableChatResponseModel } from './chat-model';
|
||||
|
||||
/**
|
||||
* Checks if the given arguments string represents empty tool arguments.
|
||||
* Handles different representations: '', undefined, '{}', '{ }', etc.
|
||||
*/
|
||||
export function isEmptyToolArgs(args: string | undefined): boolean {
|
||||
if (!args) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return typeof parsed === 'object' && !!parsed && !Array.isArray(parsed) && Object.keys(parsed).length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes tool arguments for comparison purposes.
|
||||
* Empty arguments (undefined, '', '{}') are normalized to '' for consistent comparison.
|
||||
*/
|
||||
export function normalizeToolArgs(args: string | undefined): string {
|
||||
return isEmptyToolArgs(args) ? '' : args!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context object passed to tool handlers when invoked within a chat session.
|
||||
* Extends ToolInvocationContext to include chat-specific information.
|
||||
*/
|
||||
export interface ChatToolContext extends ToolInvocationContext {
|
||||
readonly request: MutableChatRequestModel;
|
||||
readonly response: MutableChatResponseModel;
|
||||
}
|
||||
|
||||
export namespace ChatToolContext {
|
||||
export function is(obj: unknown): obj is ChatToolContext {
|
||||
return !!obj && typeof obj === 'object' && 'request' in obj && 'response' in obj;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the given context is a ChatToolContext.
|
||||
* Use this in tool handlers that require chat context to get type narrowing and runtime validation.
|
||||
* @throws Error if the context is not a valid ChatToolContext
|
||||
*/
|
||||
export function assertChatContext(ctx: unknown): asserts ctx is ChatToolContext {
|
||||
if (!ChatToolContext.is(ctx)) {
|
||||
throw new Error('This tool requires a chat context. It can only be used within a chat session.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A ToolRequest that expects a ChatToolContext.
|
||||
*/
|
||||
export type ChatToolRequest = ToolRequest<ChatToolContext>;
|
||||
|
||||
/**
|
||||
* Wraps tool requests in a chat context.
|
||||
*
|
||||
* This service extracts tool requests from a given chat request model and wraps their
|
||||
* handler functions to provide additional context, such as the chat request model.
|
||||
*/
|
||||
@injectable()
|
||||
export class ChatToolRequestService {
|
||||
|
||||
/**
|
||||
* Extracts tool requests from a chat request and wraps them to provide chat context.
|
||||
* @param request The chat request containing tool requests
|
||||
* @returns Tool requests with handlers that receive ChatToolContext
|
||||
*/
|
||||
getChatToolRequests(request: MutableChatRequestModel): ToolRequest[] {
|
||||
const toolRequests = request.message.toolRequests.size > 0 ? [...request.message.toolRequests.values()] : undefined;
|
||||
if (!toolRequests) {
|
||||
return [];
|
||||
}
|
||||
return this.toChatToolRequests(toolRequests, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps multiple tool requests to provide chat context to their handlers.
|
||||
* @param toolRequests The original tool requests
|
||||
* @param request The chat request to use for context
|
||||
* @returns Wrapped tool requests whose handlers receive ChatToolContext
|
||||
*/
|
||||
toChatToolRequests(toolRequests: ToolRequest[] | undefined, request: MutableChatRequestModel): ToolRequest[] {
|
||||
if (!toolRequests) {
|
||||
return [];
|
||||
}
|
||||
return toolRequests.map(toolRequest => this.toChatToolRequest(toolRequest, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a single tool request to provide chat context to its handler.
|
||||
* The returned tool request accepts ToolInvocationContext but internally
|
||||
* enriches it to ChatToolContext before passing to the original handler.
|
||||
* @param toolRequest The original tool request
|
||||
* @param request The chat request to use for context
|
||||
* @returns A wrapped tool request
|
||||
*/
|
||||
protected toChatToolRequest(toolRequest: ToolRequest, request: MutableChatRequestModel): ToolRequest {
|
||||
return {
|
||||
...toolRequest,
|
||||
handler: async (arg_string: string, ctx?: ToolInvocationContext) =>
|
||||
toolRequest.handler(arg_string, this.createToolContext(request, ctx))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ChatToolContext by enriching a ToolInvocationContext with chat-specific data.
|
||||
* @param request The chat request providing context
|
||||
* @param ctx The base tool invocation context
|
||||
* @returns A ChatToolContext with request, response, and cancellation token
|
||||
*/
|
||||
protected createToolContext(request: MutableChatRequestModel, ctx?: ToolInvocationContext): ChatToolContext {
|
||||
return {
|
||||
request,
|
||||
toolCallId: ctx?.toolCallId,
|
||||
cancellationToken: request.response.cancellationToken,
|
||||
get response(): MutableChatResponseModel {
|
||||
return request.response;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
53
packages/ai-chat/src/common/context-details-variable.ts
Normal file
53
packages/ai-chat/src/common/context-details-variable.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from '@theia/ai-core';
|
||||
import { dataToJsonCodeBlock } from './chat-string-utils';
|
||||
import { ChatSessionContext } from './chat-agents';
|
||||
import { CHAT_CONTEXT_DETAILS_VARIABLE_ID } from './context-variables';
|
||||
|
||||
export const CONTEXT_DETAILS_VARIABLE: AIVariable = {
|
||||
id: CHAT_CONTEXT_DETAILS_VARIABLE_ID,
|
||||
description: nls.localize('theia/ai/core/contextDetailsVariable/description', 'Provides full text values and descriptions for all context elements.'),
|
||||
name: CHAT_CONTEXT_DETAILS_VARIABLE_ID,
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class ContextDetailsVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(CONTEXT_DETAILS_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
|
||||
return request.variable.name === CONTEXT_DETAILS_VARIABLE.name ? 50 : 0;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
/** By expecting context.request, we're assuming that this variable will not be resolved until the context has been resolved. */
|
||||
if (!ChatSessionContext.is(context) || request.variable.name !== CONTEXT_DETAILS_VARIABLE.name || !context.request) { return undefined; }
|
||||
const data = context.request.context.variables.map(variable => ({
|
||||
type: variable.variable.name,
|
||||
ref: variable.value,
|
||||
content: variable.contextValue
|
||||
}));
|
||||
return {
|
||||
variable: CONTEXT_DETAILS_VARIABLE,
|
||||
value: dataToJsonCodeBlock(data)
|
||||
};
|
||||
}
|
||||
}
|
||||
53
packages/ai-chat/src/common/context-summary-variable.ts
Normal file
53
packages/ai-chat/src/common/context-summary-variable.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from '@theia/ai-core';
|
||||
import { dataToJsonCodeBlock } from './chat-string-utils';
|
||||
import { ChatSessionContext } from './chat-agents';
|
||||
|
||||
export const CONTEXT_SUMMARY_VARIABLE: AIVariable = {
|
||||
id: 'contextSummary',
|
||||
description: nls.localize('theia/ai/core/contextSummaryVariable/description', 'Describes files in the context for a given session.'),
|
||||
name: 'contextSummary',
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class ContextSummaryVariableContribution implements AIVariableContribution, AIVariableResolver {
|
||||
registerVariables(service: AIVariableService): void {
|
||||
service.registerResolver(CONTEXT_SUMMARY_VARIABLE, this);
|
||||
}
|
||||
|
||||
canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise<number> {
|
||||
return request.variable.name === CONTEXT_SUMMARY_VARIABLE.name ? 50 : 0;
|
||||
}
|
||||
|
||||
async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
|
||||
if (!ChatSessionContext.is(context) || request.variable.name !== CONTEXT_SUMMARY_VARIABLE.name) { return undefined; }
|
||||
const data = ChatSessionContext.getVariables(context).filter(variable => variable.variable.isContextVariable)
|
||||
.map(variable => ({
|
||||
type: variable.variable.name,
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
instanceData: variable.arg || null,
|
||||
contextElementId: variable.variable.id + variable.arg
|
||||
}));
|
||||
return {
|
||||
variable: CONTEXT_SUMMARY_VARIABLE,
|
||||
value: dataToJsonCodeBlock(data)
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/ai-chat/src/common/context-variables.ts
Normal file
19
packages/ai-chat/src/common/context-variables.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
export const CHAT_CONTEXT_VARIABLE_ID = 'contextSummary';
|
||||
export const CHAT_CONTEXT_DETAILS_VARIABLE_ID = 'contextDetails';
|
||||
export const CHANGE_SET_SUMMARY_VARIABLE_ID = 'changeSetSummary';
|
||||
33
packages/ai-chat/src/common/custom-chat-agent.ts
Normal file
33
packages/ai-chat/src/common/custom-chat-agent.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { LanguageModelRequirement } from '@theia/ai-core';
|
||||
import { AbstractStreamParsingChatAgent } from './chat-agents';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
|
||||
@injectable()
|
||||
export class CustomChatAgent extends AbstractStreamParsingChatAgent {
|
||||
id: string = 'CustomChatAgent';
|
||||
name: string = 'CustomChatAgent';
|
||||
languageModelRequirements: LanguageModelRequirement[] = [{ purpose: 'chat' }];
|
||||
protected defaultLanguageModelPurpose: string = 'chat';
|
||||
|
||||
set prompt(prompt: string) {
|
||||
// the name is dynamic, so we set the propmptId here
|
||||
this.systemPromptId = `${this.name}_prompt`;
|
||||
this.prompts.push({ id: this.systemPromptId, defaultVariant: { id: `${this.name}_prompt`, template: prompt } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { AIVariableResolutionRequest } from '@theia/ai-core';
|
||||
import { ImageContextVariable, IMAGE_CONTEXT_VARIABLE } from './image-context-variable';
|
||||
|
||||
describe('ImageContextVariable origin', () => {
|
||||
it('defaults to context when origin is missing', () => {
|
||||
expect(ImageContextVariable.getOrigin(JSON.stringify({ data: 'a', mimeType: 'image/png' }))).to.equal('context');
|
||||
});
|
||||
|
||||
it('returns temporary when origin is temporary', () => {
|
||||
expect(ImageContextVariable.getOrigin(JSON.stringify({ data: 'a', mimeType: 'image/png', origin: 'temporary' }))).to.equal('temporary');
|
||||
});
|
||||
|
||||
it('returns context for unknown origin values', () => {
|
||||
expect(ImageContextVariable.getOrigin(JSON.stringify({ data: 'a', mimeType: 'image/png', origin: 'other' }))).to.equal('context');
|
||||
});
|
||||
|
||||
it('returns context on invalid JSON', () => {
|
||||
expect(ImageContextVariable.getOrigin('{')).to.equal('context');
|
||||
});
|
||||
|
||||
it('getOriginSafe returns undefined for non-image requests', () => {
|
||||
expect(ImageContextVariable.getOriginSafe({ variable: { id: 'other', name: 'other', description: '' }, arg: 'x' })).to.be.undefined;
|
||||
});
|
||||
|
||||
it('getOriginSafe returns undefined if request shape is invalid', () => {
|
||||
// missing arg
|
||||
expect(ImageContextVariable.getOriginSafe({ variable: IMAGE_CONTEXT_VARIABLE } as unknown as AIVariableResolutionRequest)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
70
packages/ai-chat/src/common/image-context-variable.spec.ts
Normal file
70
packages/ai-chat/src/common/image-context-variable.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { ImageContextVariable, IMAGE_CONTEXT_VARIABLE } from './image-context-variable';
|
||||
|
||||
describe('ImageContextVariable', () => {
|
||||
describe('getOrigin', () => {
|
||||
it("should default missing origin to 'context'", () => {
|
||||
const arg = JSON.stringify({
|
||||
data: 'AAA',
|
||||
mimeType: 'image/png'
|
||||
});
|
||||
expect(ImageContextVariable.getOrigin(arg)).to.equal('context');
|
||||
});
|
||||
|
||||
it("should return 'context' on parse failure", () => {
|
||||
expect(ImageContextVariable.getOrigin('{not json')).to.equal('context');
|
||||
});
|
||||
|
||||
it("should return 'temporary' when origin is temporary", () => {
|
||||
const arg = JSON.stringify({
|
||||
data: 'AAA',
|
||||
mimeType: 'image/png',
|
||||
origin: 'temporary'
|
||||
});
|
||||
expect(ImageContextVariable.getOrigin(arg)).to.equal('temporary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArg', () => {
|
||||
it('should throw on parse failure', () => {
|
||||
expect(() => ImageContextVariable.parseArg('{not json')).to.throw('Failed to parse JSON argument string');
|
||||
});
|
||||
|
||||
it('should not clear required fields when optional origin is missing', () => {
|
||||
const arg = JSON.stringify({
|
||||
data: 'AAA',
|
||||
mimeType: 'image/png'
|
||||
});
|
||||
const parsed = ImageContextVariable.parseArg(arg);
|
||||
expect(parsed.data).to.equal('AAA');
|
||||
expect(parsed.mimeType).to.equal('image/png');
|
||||
expect(parsed.origin).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRequest', () => {
|
||||
it('should return undefined for non-imageContext requests even if arg is invalid JSON', () => {
|
||||
const parsed = ImageContextVariable.parseRequest({
|
||||
variable: { ...IMAGE_CONTEXT_VARIABLE, id: 'other', name: 'other' },
|
||||
arg: '{not json'
|
||||
});
|
||||
expect(parsed).to.equal(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
packages/ai-chat/src/common/image-context-variable.ts
Normal file
158
packages/ai-chat/src/common/image-context-variable.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import {
|
||||
AIVariable,
|
||||
AIVariableResolutionRequest,
|
||||
ResolvedAIContextVariable
|
||||
} from '@theia/ai-core';
|
||||
import { nls } from '@theia/core';
|
||||
|
||||
export const IMAGE_CONTEXT_VARIABLE: AIVariable = {
|
||||
id: 'imageContext',
|
||||
description: nls.localize('theia/ai/chat/imageContextVariable/description', 'Provides context information for an image'),
|
||||
name: 'imageContext',
|
||||
label: nls.localize('theia/ai/chat/imageContextVariable/label', 'Image File'),
|
||||
iconClasses: ['codicon', 'codicon-file-media'],
|
||||
isContextVariable: true,
|
||||
args: [
|
||||
{
|
||||
name: 'name',
|
||||
description: nls.localize('theia/ai/chat/imageContextVariable/args/name/description', 'The name of the image file if available.'),
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: 'wsRelativePath',
|
||||
description: nls.localize('theia/ai/chat/imageContextVariable/args/wsRelativePath/description', 'The workspace-relative path of the image file if available.'),
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: 'data',
|
||||
description: nls.localize('theia/ai/chat/imageContextVariable/args/data/description', 'The image data in base64.')
|
||||
},
|
||||
{
|
||||
name: 'mimeType',
|
||||
description: nls.localize('theia/ai/chat/imageContextVariable/args/mimeType/description', 'The mimetype of the image.')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export type ImageContextVariableOrigin = 'temporary' | 'context';
|
||||
|
||||
export interface ImageContextVariable {
|
||||
name?: string;
|
||||
wsRelativePath?: string;
|
||||
data: string;
|
||||
mimeType: string;
|
||||
/**
|
||||
* Internal metadata. If missing it is treated as 'context'.
|
||||
*/
|
||||
origin?: ImageContextVariableOrigin;
|
||||
}
|
||||
|
||||
export interface ImageContextVariableRequest extends AIVariableResolutionRequest {
|
||||
variable: typeof IMAGE_CONTEXT_VARIABLE;
|
||||
arg: string;
|
||||
}
|
||||
|
||||
export namespace ImageContextVariable {
|
||||
export const name = 'name';
|
||||
export const wsRelativePath = 'wsRelativePath';
|
||||
export const data = 'data';
|
||||
export const mimeType = 'mimeType';
|
||||
export const origin = 'origin';
|
||||
|
||||
export function isImageContextRequest(request: object): request is ImageContextVariableRequest {
|
||||
return AIVariableResolutionRequest.is(request) && request.variable.id === IMAGE_CONTEXT_VARIABLE.id && !!request.arg;
|
||||
}
|
||||
|
||||
export function isResolvedImageContext(resolved: object): resolved is ResolvedAIContextVariable & { arg: string } {
|
||||
return ResolvedAIContextVariable.is(resolved) && resolved.variable.id === IMAGE_CONTEXT_VARIABLE.id && !!resolved.arg;
|
||||
}
|
||||
|
||||
export function parseRequest(request: AIVariableResolutionRequest): undefined | ImageContextVariable {
|
||||
return isImageContextRequest(request) ? parseArg(request.arg) : undefined;
|
||||
}
|
||||
|
||||
export function resolve(request: ImageContextVariableRequest): ResolvedAIContextVariable {
|
||||
const args = parseArg(request.arg);
|
||||
return {
|
||||
...request,
|
||||
value: args.wsRelativePath ?? args.name ?? 'Image',
|
||||
contextValue: args.wsRelativePath ?? args.name ?? 'Image'
|
||||
};
|
||||
}
|
||||
|
||||
export function parseResolved(resolved: ResolvedAIContextVariable): undefined | ImageContextVariable {
|
||||
return isResolvedImageContext(resolved) ? parseArg(resolved.arg) : undefined;
|
||||
}
|
||||
|
||||
export function createRequest(content: ImageContextVariable): ImageContextVariableRequest {
|
||||
return {
|
||||
variable: IMAGE_CONTEXT_VARIABLE,
|
||||
arg: createArgString(content)
|
||||
};
|
||||
}
|
||||
|
||||
export function createArgString(args: ImageContextVariable): string {
|
||||
return JSON.stringify(args);
|
||||
}
|
||||
|
||||
export function parseArg(argString: string): ImageContextVariable {
|
||||
const result: Partial<ImageContextVariable> = {};
|
||||
|
||||
if (!argString) {
|
||||
throw new Error('Invalid argument string: empty string');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(argString) as Partial<ImageContextVariable>;
|
||||
Object.assign(result, parsed);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse JSON argument string: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error(`Missing required argument: ${data}`);
|
||||
}
|
||||
|
||||
if (!result.mimeType) {
|
||||
throw new Error(`Missing required argument: ${mimeType}`);
|
||||
}
|
||||
|
||||
return result as ImageContextVariable;
|
||||
}
|
||||
|
||||
export function getOrigin(argString: string): ImageContextVariableOrigin {
|
||||
try {
|
||||
const parsed = JSON.parse(argString) as { origin?: unknown };
|
||||
return parsed.origin === 'temporary' ? 'temporary' : 'context';
|
||||
} catch {
|
||||
return 'context';
|
||||
}
|
||||
}
|
||||
|
||||
export function getOriginSafe(request: AIVariableResolutionRequest): ImageContextVariableOrigin | undefined {
|
||||
if (!isImageContextRequest(request)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return getOrigin(request.arg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
29
packages/ai-chat/src/common/index.ts
Normal file
29
packages/ai-chat/src/common/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
export * from './chat-agents';
|
||||
export * from './chat-agent-service';
|
||||
export * from './chat-agent-recommendation-service';
|
||||
export * from './chat-model';
|
||||
export * from './chat-model-serialization';
|
||||
export * from './chat-content-deserializer';
|
||||
export * from './chat-model-util';
|
||||
export * from './chat-request-parser';
|
||||
export * from './chat-service';
|
||||
export * from './chat-session-store';
|
||||
export * from './custom-chat-agent';
|
||||
export * from './parsed-chat-request';
|
||||
export * from './context-variables';
|
||||
export * from './chat-tool-request-service';
|
||||
@@ -0,0 +1,125 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { MutableChatRequestModel, CodeChatResponseContentImpl, MarkdownChatResponseContentImpl } from './chat-model';
|
||||
import { parseContents } from './parse-contents';
|
||||
import { ResponseContentMatcher } from './response-content-matcher';
|
||||
|
||||
const fakeRequest = {} as MutableChatRequestModel;
|
||||
|
||||
// Custom matchers with incompleteContentFactory for testing
|
||||
const TestCodeContentMatcher: ResponseContentMatcher = {
|
||||
start: /^```.*?$/m,
|
||||
end: /^```$/m,
|
||||
contentFactory: (content: string) => {
|
||||
const language = content.match(/^```(\w+)/)?.[1] || '';
|
||||
const code = content.replace(/^```(\w+)?\n|```$/g, '');
|
||||
return new CodeChatResponseContentImpl(code.trim(), language);
|
||||
},
|
||||
incompleteContentFactory: (content: string) => {
|
||||
const language = content.match(/^```(\w+)/)?.[1] || '';
|
||||
// Remove only the start delimiter, since we don't have an end delimiter yet
|
||||
const code = content.replace(/^```(\w+)?\n?/g, '');
|
||||
return new CodeChatResponseContentImpl(code.trim(), language);
|
||||
}
|
||||
};
|
||||
|
||||
describe('parseContents with incomplete parts', () => {
|
||||
it('should handle incomplete code blocks with incompleteContentFactory', () => {
|
||||
// Only the start of a code block without an end
|
||||
const text = '```typescript\nconsole.log("Hello World");';
|
||||
const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
|
||||
|
||||
expect(result.length).to.equal(1);
|
||||
expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
|
||||
const codeContent = result[0] as CodeChatResponseContentImpl;
|
||||
expect(codeContent.code).to.equal('console.log("Hello World");');
|
||||
expect(codeContent.language).to.equal('typescript');
|
||||
});
|
||||
|
||||
it('should handle complete code blocks with contentFactory', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");\n```';
|
||||
const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
|
||||
|
||||
expect(result.length).to.equal(1);
|
||||
expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
|
||||
const codeContent = result[0] as CodeChatResponseContentImpl;
|
||||
expect(codeContent.code).to.equal('console.log("Hello World");');
|
||||
expect(codeContent.language).to.equal('typescript');
|
||||
});
|
||||
|
||||
it('should handle mixed content with incomplete and complete blocks', () => {
|
||||
const text = 'Some text\n```typescript\nconsole.log("Hello");\n```\nMore text\n```python\nprint("World")';
|
||||
const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
|
||||
|
||||
expect(result.length).to.equal(4);
|
||||
expect(result[0]).to.be.instanceOf(MarkdownChatResponseContentImpl);
|
||||
expect(result[1]).to.be.instanceOf(CodeChatResponseContentImpl);
|
||||
const completeContent = result[1] as CodeChatResponseContentImpl;
|
||||
expect(completeContent.language).to.equal('typescript');
|
||||
expect(result[2]).to.be.instanceOf(MarkdownChatResponseContentImpl);
|
||||
expect(result[3]).to.be.instanceOf(CodeChatResponseContentImpl);
|
||||
const incompleteContent = result[3] as CodeChatResponseContentImpl;
|
||||
expect(incompleteContent.language).to.equal('python');
|
||||
});
|
||||
|
||||
it('should use default content factory if no incompleteContentFactory provided', () => {
|
||||
// Create a matcher without incompleteContentFactory
|
||||
const matcherWithoutIncomplete: ResponseContentMatcher = {
|
||||
start: /^<test>$/m,
|
||||
end: /^<\/test>$/m,
|
||||
contentFactory: (content: string) => new MarkdownChatResponseContentImpl('complete: ' + content)
|
||||
};
|
||||
|
||||
// Text with only the start delimiter
|
||||
const text = '<test>\ntest content';
|
||||
const result = parseContents(text, fakeRequest, [matcherWithoutIncomplete]);
|
||||
|
||||
expect(result.length).to.equal(1);
|
||||
expect(result[0]).to.be.instanceOf(MarkdownChatResponseContentImpl);
|
||||
expect((result[0] as MarkdownChatResponseContentImpl).content.value).to.equal('<test>\ntest content');
|
||||
});
|
||||
|
||||
it('should handle incomplete code blocks without language identifier', () => {
|
||||
const text = '```\nsome code';
|
||||
const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
|
||||
|
||||
expect(result.length).to.equal(1);
|
||||
expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
|
||||
const codeContent = result[0] as CodeChatResponseContentImpl;
|
||||
expect(codeContent.code).to.equal('some code');
|
||||
expect(codeContent.language).to.equal('');
|
||||
});
|
||||
|
||||
it('should prefer complete matches over incomplete ones', () => {
|
||||
// Text with both a complete and incomplete match at same position
|
||||
const text = '```typescript\nconsole.log();\n```\n<test>\ntest content';
|
||||
const matcherWithoutIncomplete: ResponseContentMatcher = {
|
||||
start: /^<test>$/m,
|
||||
end: /^<\/test>$/m,
|
||||
contentFactory: (content: string) => new MarkdownChatResponseContentImpl('complete: ' + content)
|
||||
};
|
||||
|
||||
const result = parseContents(text, fakeRequest, [TestCodeContentMatcher, matcherWithoutIncomplete]);
|
||||
|
||||
expect(result.length).to.equal(2);
|
||||
expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
|
||||
expect((result[0] as CodeChatResponseContentImpl).language).to.equal('typescript');
|
||||
expect(result[1]).to.be.instanceOf(MarkdownChatResponseContentImpl);
|
||||
expect((result[1] as MarkdownChatResponseContentImpl).content.value).to.contain('test content');
|
||||
});
|
||||
});
|
||||
152
packages/ai-chat/src/common/parse-contents.spec.ts
Normal file
152
packages/ai-chat/src/common/parse-contents.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import { MutableChatRequestModel, ChatResponseContent, CodeChatResponseContentImpl, MarkdownChatResponseContentImpl } from './chat-model';
|
||||
import { parseContents } from './parse-contents';
|
||||
import { CodeContentMatcher, ResponseContentMatcher } from './response-content-matcher';
|
||||
|
||||
export class CommandChatResponseContentImpl implements ChatResponseContent {
|
||||
constructor(public readonly command: string) { }
|
||||
kind = 'command';
|
||||
}
|
||||
|
||||
export const CommandContentMatcher: ResponseContentMatcher = {
|
||||
start: /^<command>$/m,
|
||||
end: /^<\/command>$/m,
|
||||
contentFactory: (content: string) => {
|
||||
const code = content.replace(/^<command>\n|<\/command>$/g, '');
|
||||
return new CommandChatResponseContentImpl(code.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const fakeRequest = {} as MutableChatRequestModel;
|
||||
|
||||
describe('parseContents', () => {
|
||||
it('should parse code content', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");\n```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript')]);
|
||||
});
|
||||
|
||||
it('should parse markdown content', () => {
|
||||
const text = 'Hello **World**';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('Hello **World**')]);
|
||||
});
|
||||
|
||||
it('should parse multiple content blocks', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");\n```\nHello **World**';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
|
||||
new MarkdownChatResponseContentImpl('\nHello **World**')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse multiple content blocks with different languages', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");\n```\n```python\nprint("Hello World")\n```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
|
||||
new CodeChatResponseContentImpl('print("Hello World")', 'python')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse multiple content blocks with different languages and markdown', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");\n```\nHello **World**\n```python\nprint("Hello World")\n```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
|
||||
new MarkdownChatResponseContentImpl('\nHello **World**\n'),
|
||||
new CodeChatResponseContentImpl('print("Hello World")', 'python')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse content blocks with empty content', () => {
|
||||
const text = '```typescript\n```\nHello **World**\n```python\nprint("Hello World")\n```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new CodeChatResponseContentImpl('', 'typescript'),
|
||||
new MarkdownChatResponseContentImpl('\nHello **World**\n'),
|
||||
new CodeChatResponseContentImpl('print("Hello World")', 'python')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse content with markdown, code, and markdown', () => {
|
||||
const text = 'Hello **World**\n```typescript\nconsole.log("Hello World");\n```\nGoodbye **World**';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new MarkdownChatResponseContentImpl('Hello **World**\n'),
|
||||
new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
|
||||
new MarkdownChatResponseContentImpl('\nGoodbye **World**')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle text with no special content', () => {
|
||||
const text = 'Just some plain text.';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('Just some plain text.')]);
|
||||
});
|
||||
|
||||
it('should handle text with only start code block', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");';
|
||||
// We're using the standard CodeContentMatcher which has incompleteContentFactory
|
||||
const result = parseContents(text, fakeRequest);
|
||||
expect(result).to.deep.equal([new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript')]);
|
||||
});
|
||||
|
||||
it('should handle text with only end code block', () => {
|
||||
const text = 'console.log("Hello World");\n```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('console.log("Hello World");\n```')]);
|
||||
});
|
||||
|
||||
it('should handle text with unmatched code block', () => {
|
||||
const text = '```typescript\nconsole.log("Hello World");\n```\n```python\nprint("Hello World")';
|
||||
// We're using the standard CodeContentMatcher which has incompleteContentFactory
|
||||
const result = parseContents(text, fakeRequest);
|
||||
expect(result).to.deep.equal([
|
||||
new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
|
||||
new CodeChatResponseContentImpl('print("Hello World")', 'python')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse code block without newline after language', () => {
|
||||
const text = '```typescript console.log("Hello World");```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new MarkdownChatResponseContentImpl('```typescript console.log("Hello World");```')
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse code content without language identifier', () => {
|
||||
const text = '```\nsome code\n```';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher]);
|
||||
expect(result).to.deep.equal([new CodeChatResponseContentImpl('some code', '')]);
|
||||
});
|
||||
|
||||
it('should parse with matches of multiple different matchers and default', () => {
|
||||
const text = '<command>\nMY_SPECIAL_COMMAND\n</command>\nHello **World**\n```python\nprint("Hello World")\n```\n<command>\nMY_SPECIAL_COMMAND2\n</command>';
|
||||
const result = parseContents(text, fakeRequest, [CodeContentMatcher, CommandContentMatcher]);
|
||||
expect(result).to.deep.equal([
|
||||
new CommandChatResponseContentImpl('MY_SPECIAL_COMMAND'),
|
||||
new MarkdownChatResponseContentImpl('\nHello **World**\n'),
|
||||
new CodeChatResponseContentImpl('print("Hello World")', 'python'),
|
||||
new CommandChatResponseContentImpl('MY_SPECIAL_COMMAND2'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
139
packages/ai-chat/src/common/parse-contents.ts
Normal file
139
packages/ai-chat/src/common/parse-contents.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2024 EclipseSource GmbH.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License v. 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0.
|
||||
*
|
||||
* This Source Code may also be made available under the following Secondary
|
||||
* Licenses when the conditions for such availability set forth in the Eclipse
|
||||
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
* with the GNU Classpath Exception which is available at
|
||||
* https://www.gnu.org/software/classpath/license.html.
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
*/
|
||||
import { MutableChatRequestModel, ChatResponseContent } from './chat-model';
|
||||
import { CodeContentMatcher, MarkdownContentFactory, ResponseContentFactory, ResponseContentMatcher } from './response-content-matcher';
|
||||
|
||||
interface Match {
|
||||
matcher: ResponseContentMatcher;
|
||||
index: number;
|
||||
content: string;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export function parseContents(
|
||||
text: string,
|
||||
request: MutableChatRequestModel,
|
||||
contentMatchers: ResponseContentMatcher[] = [CodeContentMatcher],
|
||||
defaultContentFactory: ResponseContentFactory = MarkdownContentFactory
|
||||
): ChatResponseContent[] {
|
||||
const result: ChatResponseContent[] = [];
|
||||
|
||||
let currentIndex = 0;
|
||||
while (currentIndex < text.length) {
|
||||
const remainingText = text.substring(currentIndex);
|
||||
const match = findFirstMatch(contentMatchers, remainingText);
|
||||
if (!match) {
|
||||
// Add the remaining text as default content
|
||||
if (remainingText.length > 0) {
|
||||
result.push(defaultContentFactory(remainingText, request));
|
||||
}
|
||||
break;
|
||||
}
|
||||
// We have a match
|
||||
// 1. Add preceding text as default content
|
||||
if (match.index > 0) {
|
||||
const precedingContent = remainingText.substring(0, match.index);
|
||||
if (precedingContent.trim().length > 0) {
|
||||
result.push(defaultContentFactory(precedingContent, request));
|
||||
}
|
||||
}
|
||||
// 2. Add the matched content object
|
||||
if (match.isComplete) {
|
||||
// Complete match, use regular content factory
|
||||
result.push(match.matcher.contentFactory(match.content, request));
|
||||
} else if (match.matcher.incompleteContentFactory) {
|
||||
// Incomplete match with an incomplete content factory available
|
||||
result.push(match.matcher.incompleteContentFactory(match.content, request));
|
||||
} else {
|
||||
// Incomplete match but no incomplete content factory available, use default
|
||||
result.push(defaultContentFactory(match.content, request));
|
||||
}
|
||||
// Update currentIndex to the end of the end of the match
|
||||
// And continue with the search after the end of the match
|
||||
currentIndex += match.index + match.content.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findFirstMatch(contentMatchers: ResponseContentMatcher[], text: string): Match | undefined {
|
||||
let firstMatch: Match | undefined;
|
||||
let firstIncompleteMatch: Match | undefined;
|
||||
|
||||
for (const matcher of contentMatchers) {
|
||||
const startMatch = matcher.start.exec(text);
|
||||
if (!startMatch) {
|
||||
// No start match found, try next matcher.
|
||||
continue;
|
||||
}
|
||||
const endOfStartMatch = startMatch.index + startMatch[0].length;
|
||||
if (endOfStartMatch >= text.length) {
|
||||
// There is no text after the start match.
|
||||
// This is an incomplete match if the matcher has an incompleteContentFactory
|
||||
if (matcher.incompleteContentFactory) {
|
||||
const incompleteMatch: Match = {
|
||||
matcher,
|
||||
index: startMatch.index,
|
||||
content: text.substring(startMatch.index),
|
||||
isComplete: false
|
||||
};
|
||||
if (!firstIncompleteMatch || incompleteMatch.index < firstIncompleteMatch.index) {
|
||||
firstIncompleteMatch = incompleteMatch;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainingTextAfterStartMatch = text.substring(endOfStartMatch);
|
||||
const endMatch = matcher.end.exec(remainingTextAfterStartMatch);
|
||||
|
||||
if (!endMatch) {
|
||||
// No end match found, this is an incomplete match
|
||||
if (matcher.incompleteContentFactory) {
|
||||
const incompleteMatch: Match = {
|
||||
matcher,
|
||||
index: startMatch.index,
|
||||
content: text.substring(startMatch.index),
|
||||
isComplete: false
|
||||
};
|
||||
if (!firstIncompleteMatch || incompleteMatch.index < firstIncompleteMatch.index) {
|
||||
firstIncompleteMatch = incompleteMatch;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found start and end match.
|
||||
// Record the full match, if it is the earliest found so far.
|
||||
const index = startMatch.index;
|
||||
const contentEnd = index + startMatch[0].length + endMatch.index + endMatch[0].length;
|
||||
const content = text.substring(index, contentEnd);
|
||||
const completeMatch: Match = { matcher, index, content, isComplete: true };
|
||||
|
||||
if (!firstMatch || index < firstMatch.index) {
|
||||
firstMatch = completeMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a complete match, return it
|
||||
if (firstMatch) {
|
||||
return firstMatch;
|
||||
}
|
||||
|
||||
// Otherwise, return the first incomplete match if one exists
|
||||
return firstIncompleteMatch;
|
||||
}
|
||||
|
||||
182
packages/ai-chat/src/common/parsed-chat-request.ts
Normal file
182
packages/ai-chat/src/common/parsed-chat-request.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatParserTypes.ts
|
||||
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/editor/common/core/offsetRange.ts
|
||||
|
||||
import { ResolvedAIVariable, ToolRequest, toolRequestToPromptText } from '@theia/ai-core';
|
||||
import { ChatRequest } from './chat-model';
|
||||
import {
|
||||
SerializableTextPart,
|
||||
SerializableVariablePart,
|
||||
SerializableFunctionPart,
|
||||
SerializableAgentPart,
|
||||
SerializableParsedRequest,
|
||||
} from './chat-model-serialization';
|
||||
|
||||
export const chatVariableLeader = '#';
|
||||
export const chatAgentLeader = '@';
|
||||
export const chatFunctionLeader = '~';
|
||||
export const chatSubcommandLeader = '/';
|
||||
|
||||
/**********************
|
||||
* CLASSES, INTERFACES AND TYPE GUARDS
|
||||
**********************/
|
||||
|
||||
export interface OffsetRange {
|
||||
readonly start: number;
|
||||
readonly endExclusive: number;
|
||||
}
|
||||
|
||||
export interface ParsedChatRequest {
|
||||
readonly request: ChatRequest;
|
||||
readonly parts: ParsedChatRequestPart[];
|
||||
readonly toolRequests: Map<string, ToolRequest>;
|
||||
readonly variables: ResolvedAIVariable[];
|
||||
}
|
||||
|
||||
export interface ParsedChatRequestPart {
|
||||
readonly kind: string;
|
||||
/**
|
||||
* The text as represented in the ChatRequest
|
||||
*/
|
||||
readonly text: string;
|
||||
/**
|
||||
* The text as will be sent to the LLM
|
||||
*/
|
||||
readonly promptText: string;
|
||||
|
||||
readonly range: OffsetRange;
|
||||
}
|
||||
|
||||
export class ParsedChatRequestTextPart implements ParsedChatRequestPart {
|
||||
readonly kind = 'text';
|
||||
|
||||
constructor(readonly range: OffsetRange, readonly text: string) { }
|
||||
|
||||
get promptText(): string {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
toSerializable(): SerializableTextPart {
|
||||
return {
|
||||
kind: 'text',
|
||||
range: this.range,
|
||||
text: this.text
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedChatRequestVariablePart implements ParsedChatRequestPart {
|
||||
readonly kind = 'var';
|
||||
|
||||
public resolution: ResolvedAIVariable;
|
||||
|
||||
constructor(readonly range: OffsetRange, readonly variableName: string, readonly variableArg: string | undefined) { }
|
||||
|
||||
get text(): string {
|
||||
const argPart = this.variableArg ? `:${this.variableArg}` : '';
|
||||
return `${chatVariableLeader}${this.variableName}${argPart}`;
|
||||
}
|
||||
|
||||
get promptText(): string {
|
||||
return this.resolution?.value ?? this.text;
|
||||
}
|
||||
|
||||
toSerializable(): SerializableVariablePart {
|
||||
return {
|
||||
kind: 'var',
|
||||
range: this.range,
|
||||
variableId: this.resolution?.variable.id ?? 'unresolved variable',
|
||||
variableName: this.variableName,
|
||||
variableArg: this.variableArg,
|
||||
variableValue: this.resolution?.value,
|
||||
variableDescription: this.resolution?.variable.description ?? 'unresolved variable'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedChatRequestFunctionPart implements ParsedChatRequestPart {
|
||||
readonly kind = 'function';
|
||||
constructor(readonly range: OffsetRange, readonly toolRequest: ToolRequest) { }
|
||||
|
||||
get text(): string {
|
||||
return `${chatFunctionLeader}${this.toolRequest.id}`;
|
||||
}
|
||||
|
||||
get promptText(): string {
|
||||
return toolRequestToPromptText(this.toolRequest);
|
||||
}
|
||||
|
||||
toSerializable(): SerializableFunctionPart {
|
||||
return {
|
||||
kind: 'function',
|
||||
range: this.range,
|
||||
toolRequestId: this.toolRequest.id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ParsedChatRequestAgentPart implements ParsedChatRequestPart {
|
||||
readonly kind = 'agent';
|
||||
constructor(readonly range: OffsetRange, readonly agentId: string, readonly agentName: string) { }
|
||||
|
||||
get text(): string {
|
||||
return `${chatAgentLeader}${this.agentName}`;
|
||||
}
|
||||
|
||||
get promptText(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
toSerializable(): SerializableAgentPart {
|
||||
return {
|
||||
kind: 'agent',
|
||||
range: this.range,
|
||||
agentId: this.agentId,
|
||||
agentName: this.agentName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ParsedChatRequest {
|
||||
export function toSerializable(parsed: ParsedChatRequest): SerializableParsedRequest {
|
||||
return {
|
||||
parts: parsed.parts.map(part => {
|
||||
if (part instanceof ParsedChatRequestTextPart ||
|
||||
part instanceof ParsedChatRequestVariablePart ||
|
||||
part instanceof ParsedChatRequestFunctionPart ||
|
||||
part instanceof ParsedChatRequestAgentPart) {
|
||||
return part.toSerializable();
|
||||
}
|
||||
throw new Error(`Unknown part type: ${part.kind}`);
|
||||
}),
|
||||
toolRequests: Array.from(parsed.toolRequests.keys()).map(toolId => ({
|
||||
id: toolId
|
||||
})),
|
||||
variables: parsed.variables.map(variable => ({
|
||||
variableId: variable.variable.id,
|
||||
variableName: variable.variable.name,
|
||||
variableDescription: variable.variable.description,
|
||||
arg: variable.arg,
|
||||
value: variable.value
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
121
packages/ai-chat/src/common/response-content-matcher.ts
Normal file
121
packages/ai-chat/src/common/response-content-matcher.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2024 EclipseSource GmbH.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License v. 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0.
|
||||
*
|
||||
* This Source Code may also be made available under the following Secondary
|
||||
* Licenses when the conditions for such availability set forth in the Eclipse
|
||||
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
* with the GNU Classpath Exception which is available at
|
||||
* https://www.gnu.org/software/classpath/license.html.
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
*/
|
||||
import {
|
||||
MutableChatRequestModel,
|
||||
ChatResponseContent,
|
||||
CodeChatResponseContentImpl,
|
||||
MarkdownChatResponseContentImpl
|
||||
} from './chat-model';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
|
||||
export type ResponseContentFactory = (content: string, request: MutableChatRequestModel) => ChatResponseContent;
|
||||
|
||||
export const MarkdownContentFactory: ResponseContentFactory = (content: string, request: MutableChatRequestModel) =>
|
||||
new MarkdownChatResponseContentImpl(content);
|
||||
|
||||
/**
|
||||
* Default response content factory used if no other `ResponseContentMatcher` applies.
|
||||
* By default, this factory creates a markdown content object.
|
||||
*
|
||||
* @see MarkdownChatResponseContentImpl
|
||||
*/
|
||||
@injectable()
|
||||
export class DefaultResponseContentFactory {
|
||||
create(content: string, request: MutableChatRequestModel): ChatResponseContent {
|
||||
return MarkdownContentFactory(content, request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clients can contribute response content matchers to parse a chat response into specific
|
||||
* `ChatResponseContent` instances.
|
||||
*/
|
||||
export interface ResponseContentMatcher {
|
||||
/** Regular expression for finding the start delimiter. */
|
||||
start: RegExp;
|
||||
/** Regular expression for finding the start delimiter. */
|
||||
end: RegExp;
|
||||
/**
|
||||
* The factory creating a response content from the matching content,
|
||||
* from start index to end index of the match (including delimiters).
|
||||
*/
|
||||
contentFactory: ResponseContentFactory;
|
||||
/**
|
||||
* Optional factory for creating a response content when only the start delimiter has been matched,
|
||||
* but not yet the end delimiter. Used during streaming to provide better visual feedback.
|
||||
* If not provided, the default content factory will be used until the end delimiter is matched.
|
||||
*/
|
||||
incompleteContentFactory?: ResponseContentFactory;
|
||||
}
|
||||
|
||||
export const CodeContentMatcher: ResponseContentMatcher = {
|
||||
// Only match when we have the complete first line ending with a newline
|
||||
// This ensures we have the full language specification before creating the editor
|
||||
start: /^```.*\n/m,
|
||||
end: /^```$/m,
|
||||
contentFactory: (content: string, request: MutableChatRequestModel) => {
|
||||
const language = content.match(/^```(\w+)/)?.[1] || '';
|
||||
const code = content.replace(/^```(\w+)?\n|```$/g, '');
|
||||
return new CodeChatResponseContentImpl(code.trim(), language);
|
||||
},
|
||||
incompleteContentFactory: (content: string, request: MutableChatRequestModel) => {
|
||||
// By this point, we know we have at least the complete first line with ```
|
||||
const firstLine = content.split('\n')[0];
|
||||
const language = firstLine.match(/^```(\w+)/)?.[1] || '';
|
||||
|
||||
// Remove the first line to get just the code content
|
||||
const code = content.substring(content.indexOf('\n') + 1);
|
||||
|
||||
return new CodeChatResponseContentImpl(code.trim(), language);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clients can contribute response content matchers to parse the response content.
|
||||
*
|
||||
* The default chat user interface will collect all contributed matchers and use them
|
||||
* to parse the response into structured content parts (e.g. code blocks, markdown blocks),
|
||||
* which are then rendered with a `ChatResponsePartRenderer` registered for the respective
|
||||
* content part type.
|
||||
*
|
||||
* ### Example
|
||||
* ```ts
|
||||
* bind(ResponseContentMatcherProvider).to(MyResponseContentMatcherProvider);
|
||||
* ...
|
||||
* @injectable()
|
||||
* export class MyResponseContentMatcherProvider implements ResponseContentMatcherProvider {
|
||||
* readonly matchers: ResponseContentMatcher[] = [{
|
||||
* start: /^<command>$/m,
|
||||
* end: /^</command>$/m,
|
||||
* contentFactory: (content: string) => {
|
||||
* const command = content.replace(/^<command>\n|<\/command>$/g, '');
|
||||
* return new MyChatResponseContentImpl(command.trim());
|
||||
* }
|
||||
* }];
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see ResponseContentMatcher
|
||||
*/
|
||||
export const ResponseContentMatcherProvider = Symbol('ResponseContentMatcherProvider');
|
||||
export interface ResponseContentMatcherProvider {
|
||||
readonly matchers: ResponseContentMatcher[];
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class DefaultResponseContentMatcherProvider implements ResponseContentMatcherProvider {
|
||||
readonly matchers: ResponseContentMatcher[] = [CodeContentMatcher];
|
||||
}
|
||||
231
packages/ai-chat/src/common/tool-call-response-content.spec.ts
Normal file
231
packages/ai-chat/src/common/tool-call-response-content.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { ToolCallChatResponseContentImpl } from './chat-model';
|
||||
|
||||
describe('ToolCallChatResponseContentImpl', () => {
|
||||
let consoleWarnStub: sinon.SinonStub;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnStub = sinon.stub(console, 'warn');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnStub.restore();
|
||||
});
|
||||
|
||||
describe('toLanguageModelMessage', () => {
|
||||
it('should parse valid JSON arguments', () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{"key": "value", "number": 42}',
|
||||
true
|
||||
);
|
||||
|
||||
const [toolUseMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolUseMessage.input).to.deep.equal({ key: 'value', number: 42 });
|
||||
expect(consoleWarnStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should return empty object for malformed JSON and log warning', () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{"truncated": "json',
|
||||
true
|
||||
);
|
||||
|
||||
const [toolUseMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolUseMessage.input).to.deep.equal({});
|
||||
expect(consoleWarnStub.calledOnce).to.be.true;
|
||||
expect(consoleWarnStub.firstCall.args[0]).to.include('Failed to parse tool call arguments');
|
||||
expect(consoleWarnStub.firstCall.args[0]).to.include('test-tool');
|
||||
});
|
||||
|
||||
it('should return empty object for empty arguments', () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'',
|
||||
true
|
||||
);
|
||||
|
||||
const [toolUseMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolUseMessage.input).to.deep.equal({});
|
||||
expect(consoleWarnStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should return empty object for undefined arguments', () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
|
||||
const [toolUseMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolUseMessage.input).to.deep.equal({});
|
||||
expect(consoleWarnStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should handle mid-stream cancellation with truncated JSON', () => {
|
||||
// Simulates a cancelled stream that left truncated JSON
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'cancelledTool',
|
||||
'{"file": "/path/to/file.ts", "content": "partial content...',
|
||||
false // not finished
|
||||
);
|
||||
|
||||
const [toolUseMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolUseMessage.input).to.deep.equal({});
|
||||
expect(consoleWarnStub.calledOnce).to.be.true;
|
||||
expect(consoleWarnStub.firstCall.args[0]).to.include('cancelledTool');
|
||||
});
|
||||
|
||||
it('should parse complex nested JSON arguments', () => {
|
||||
const complexArgs = JSON.stringify({
|
||||
nested: { deep: { value: 123 } },
|
||||
array: [1, 2, 3],
|
||||
boolean: true
|
||||
});
|
||||
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
complexArgs,
|
||||
true
|
||||
);
|
||||
|
||||
const [toolUseMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolUseMessage.input).to.deep.equal({
|
||||
nested: { deep: { value: 123 } },
|
||||
array: [1, 2, 3],
|
||||
boolean: true
|
||||
});
|
||||
expect(consoleWarnStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should include tool result in the result message', () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{}',
|
||||
true,
|
||||
'Tool execution result'
|
||||
);
|
||||
|
||||
const [, toolResultMessage] = toolCall.toLanguageModelMessage();
|
||||
|
||||
expect(toolResultMessage.content).to.equal('Tool execution result');
|
||||
expect(toolResultMessage.tool_use_id).to.equal('test-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restored tool calls', () => {
|
||||
it('should have finished=true when restored with a result', () => {
|
||||
const restoredToolCall = new ToolCallChatResponseContentImpl(
|
||||
'restored-id',
|
||||
'shellExecute',
|
||||
'{"command": "ls -la"}',
|
||||
true,
|
||||
'{"success": true, "output": "file1.txt\\nfile2.txt"}'
|
||||
);
|
||||
|
||||
expect(restoredToolCall.finished).to.be.true;
|
||||
expect(restoredToolCall.result).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
describe('whenFinished', () => {
|
||||
it('should resolve immediately when constructed with finished=true', async () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{}',
|
||||
true
|
||||
);
|
||||
|
||||
await toolCall.whenFinished;
|
||||
expect(toolCall.finished).to.be.true;
|
||||
});
|
||||
|
||||
it('should resolve when deny is called', async () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{}'
|
||||
);
|
||||
|
||||
expect(toolCall.finished).to.be.false;
|
||||
|
||||
const finishedPromise = toolCall.whenFinished;
|
||||
toolCall.deny('test reason');
|
||||
|
||||
await finishedPromise;
|
||||
expect(toolCall.finished).to.be.true;
|
||||
});
|
||||
|
||||
it('should resolve when merged with finished content', async () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{}'
|
||||
);
|
||||
|
||||
expect(toolCall.finished).to.be.false;
|
||||
|
||||
const finishedPromise = toolCall.whenFinished;
|
||||
const finishedContent = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{}',
|
||||
true,
|
||||
'result'
|
||||
);
|
||||
toolCall.merge(finishedContent);
|
||||
|
||||
await finishedPromise;
|
||||
expect(toolCall.finished).to.be.true;
|
||||
});
|
||||
|
||||
it('should resolve when complete is called', async () => {
|
||||
const toolCall = new ToolCallChatResponseContentImpl(
|
||||
'test-id',
|
||||
'test-tool',
|
||||
'{}'
|
||||
);
|
||||
|
||||
expect(toolCall.finished).to.be.false;
|
||||
|
||||
const finishedPromise = toolCall.whenFinished;
|
||||
toolCall.complete('execution result');
|
||||
|
||||
await finishedPromise;
|
||||
expect(toolCall.finished).to.be.true;
|
||||
expect(toolCall.result).to.equal('execution result');
|
||||
});
|
||||
});
|
||||
});
|
||||
25
packages/ai-chat/src/node/ai-chat-backend-module.ts
Normal file
25
packages/ai-chat/src/node/ai-chat-backend-module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 STMicroelectronics and others
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { PreferenceContribution } from '@theia/core';
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { aiChatPreferences } from '../common/ai-chat-preferences';
|
||||
import { chatToolPreferences } from '../common/chat-tool-preferences';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(PreferenceContribution).toConstantValue({ schema: aiChatPreferences });
|
||||
bind(PreferenceContribution).toConstantValue({ schema: chatToolPreferences });
|
||||
});
|
||||
34
packages/ai-chat/tsconfig.json
Normal file
34
packages/ai-chat/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": "../../configs/base.tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ai-core"
|
||||
},
|
||||
{
|
||||
"path": "../core"
|
||||
},
|
||||
{
|
||||
"path": "../editor"
|
||||
},
|
||||
{
|
||||
"path": "../file-search"
|
||||
},
|
||||
{
|
||||
"path": "../filesystem"
|
||||
},
|
||||
{
|
||||
"path": "../monaco"
|
||||
},
|
||||
{
|
||||
"path": "../workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user