deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View 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>

View 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"
}

View 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);
}
}
}

View File

@@ -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 });
}
}

View 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);
});

View 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;
}
}

View File

@@ -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;
}
});
}
}

View 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();
}
}

View 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);
}

View 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());
}
}

View 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')}
`
};
}
}

View 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);
});
});
});

View 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();
}
}

View File

@@ -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);
});
});
});

View 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, {});
}
}
}

View 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`);
}
}

View File

@@ -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');

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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;

View File

@@ -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 {
}
}

View 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'
);
}

View File

@@ -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 });
});
});

View 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;
}
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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.')
}]
};

View 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,
}
}
};

View File

@@ -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
})
};
}
}

View 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?.());
}
}

View File

@@ -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[];
}

View 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);
}
}

View File

@@ -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, ' ')}`;
}

View 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);
}
}

View 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);
});
});
});

View 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');
});
});
});

View 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
)
});
}
}

View 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');
});
});
});

View 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;

View 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);
}

File diff suppressed because it is too large Load Diff

View 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');
});
});
});

View 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);
}
}

View 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);
});
});
});

View 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);
});
});

View 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 });
}
}
}

View File

@@ -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}}',
}
};

View 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);
}
}

View 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;
}

View File

@@ -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}}}`,
}
};

View 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 = [];
}

View 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));
}

View 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 };
}

View File

@@ -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));
});
});
});

View 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;
}
};
}
}

View 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)
};
}
}

View 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)
};
}
}

View 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';

View 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 } });
}
}

View File

@@ -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;
});
});

View 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);
});
});
});

View 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;
}
}
}

View 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';

View File

@@ -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');
});
});

View 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'),
]);
});
});

View 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;
}

View 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
}))
};
}
}

View 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];
}

View 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');
});
});
});

View 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 });
});

View 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"
}
]
}