@theia/ai-chat 1.60.2 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/lib/browser/ai-chat-frontend-contribution.d.ts +11 -0
  2. package/lib/browser/ai-chat-frontend-contribution.d.ts.map +1 -0
  3. package/lib/browser/ai-chat-frontend-contribution.js +56 -0
  4. package/lib/browser/ai-chat-frontend-contribution.js.map +1 -0
  5. package/lib/browser/ai-chat-frontend-module.d.ts.map +1 -1
  6. package/lib/browser/ai-chat-frontend-module.js +21 -3
  7. package/lib/browser/ai-chat-frontend-module.js.map +1 -1
  8. package/lib/browser/change-set-decorator-service.d.ts +24 -0
  9. package/lib/browser/change-set-decorator-service.d.ts.map +1 -0
  10. package/lib/browser/change-set-decorator-service.js +66 -0
  11. package/lib/browser/change-set-decorator-service.js.map +1 -0
  12. package/lib/browser/change-set-file-element.d.ts +7 -4
  13. package/lib/browser/change-set-file-element.d.ts.map +1 -1
  14. package/lib/browser/change-set-file-element.js +20 -12
  15. package/lib/browser/change-set-file-element.js.map +1 -1
  16. package/lib/browser/change-set-file-resource.d.ts +1 -42
  17. package/lib/browser/change-set-file-resource.d.ts.map +1 -1
  18. package/lib/browser/change-set-file-resource.js +1 -136
  19. package/lib/browser/change-set-file-resource.js.map +1 -1
  20. package/lib/browser/change-set-variable.d.ts.map +1 -1
  21. package/lib/browser/change-set-variable.js +13 -4
  22. package/lib/browser/change-set-variable.js.map +1 -1
  23. package/lib/browser/context-file-variable-label-provider.js +1 -1
  24. package/lib/browser/context-file-variable-label-provider.js.map +1 -1
  25. package/lib/browser/file-chat-variable-contribution.d.ts.map +1 -1
  26. package/lib/browser/file-chat-variable-contribution.js +29 -27
  27. package/lib/browser/file-chat-variable-contribution.js.map +1 -1
  28. package/lib/browser/task-context-service.d.ts +40 -0
  29. package/lib/browser/task-context-service.d.ts.map +1 -0
  30. package/lib/browser/task-context-service.js +148 -0
  31. package/lib/browser/task-context-service.js.map +1 -0
  32. package/lib/browser/task-context-storage-service.d.ts +18 -0
  33. package/lib/browser/task-context-storage-service.d.ts.map +1 -0
  34. package/lib/browser/task-context-storage-service.js +77 -0
  35. package/lib/browser/task-context-storage-service.js.map +1 -0
  36. package/lib/browser/task-context-variable-contribution.d.ts +20 -0
  37. package/lib/browser/task-context-variable-contribution.d.ts.map +1 -0
  38. package/lib/browser/task-context-variable-contribution.js +101 -0
  39. package/lib/browser/task-context-variable-contribution.js.map +1 -0
  40. package/lib/browser/task-context-variable-label-provider.d.ts +21 -0
  41. package/lib/browser/task-context-variable-label-provider.d.ts.map +1 -0
  42. package/lib/browser/task-context-variable-label-provider.js +83 -0
  43. package/lib/browser/task-context-variable-label-provider.js.map +1 -0
  44. package/lib/browser/task-context-variable.d.ts +3 -0
  45. package/lib/browser/task-context-variable.d.ts.map +1 -0
  46. package/lib/browser/task-context-variable.js +29 -0
  47. package/lib/browser/task-context-variable.js.map +1 -0
  48. package/lib/common/chat-agents.d.ts +2 -1
  49. package/lib/common/chat-agents.d.ts.map +1 -1
  50. package/lib/common/chat-agents.js +32 -20
  51. package/lib/common/chat-agents.js.map +1 -1
  52. package/lib/common/chat-model.d.ts +191 -8
  53. package/lib/common/chat-model.d.ts.map +1 -1
  54. package/lib/common/chat-model.js +369 -12
  55. package/lib/common/chat-model.js.map +1 -1
  56. package/lib/common/chat-request-parser.d.ts +1 -1
  57. package/lib/common/chat-request-parser.d.ts.map +1 -1
  58. package/lib/common/chat-request-parser.js +1 -1
  59. package/lib/common/chat-request-parser.js.map +1 -1
  60. package/lib/common/chat-service.d.ts +2 -0
  61. package/lib/common/chat-service.d.ts.map +1 -1
  62. package/lib/common/chat-service.js +18 -25
  63. package/lib/common/chat-service.js.map +1 -1
  64. package/lib/common/chat-session-naming-service.d.ts.map +1 -1
  65. package/lib/common/chat-session-naming-service.js +9 -11
  66. package/lib/common/chat-session-naming-service.js.map +1 -1
  67. package/lib/common/chat-session-summary-agent-prompt.d.ts +5 -0
  68. package/lib/common/chat-session-summary-agent-prompt.d.ts.map +1 -0
  69. package/lib/common/chat-session-summary-agent-prompt.js +30 -0
  70. package/lib/common/chat-session-summary-agent-prompt.js.map +1 -0
  71. package/lib/common/chat-session-summary-agent.d.ts +17 -0
  72. package/lib/common/chat-session-summary-agent.d.ts.map +1 -0
  73. package/lib/common/chat-session-summary-agent.js +48 -0
  74. package/lib/common/chat-session-summary-agent.js.map +1 -0
  75. package/lib/common/context-summary-variable.js +1 -1
  76. package/lib/common/context-summary-variable.js.map +1 -1
  77. package/lib/common/parse-contents-with-incomplete-parts.spec.d.ts +2 -0
  78. package/lib/common/parse-contents-with-incomplete-parts.spec.d.ts.map +1 -0
  79. package/lib/common/parse-contents-with-incomplete-parts.spec.js +103 -0
  80. package/lib/common/parse-contents-with-incomplete-parts.spec.js.map +1 -0
  81. package/lib/common/parse-contents.d.ts +1 -0
  82. package/lib/common/parse-contents.d.ts.map +1 -1
  83. package/lib/common/parse-contents.js +45 -5
  84. package/lib/common/parse-contents.js.map +1 -1
  85. package/lib/common/parse-contents.spec.d.ts +1 -0
  86. package/lib/common/parse-contents.spec.d.ts.map +1 -1
  87. package/lib/common/parse-contents.spec.js +25 -13
  88. package/lib/common/parse-contents.spec.js.map +1 -1
  89. package/lib/common/response-content-matcher.d.ts +6 -0
  90. package/lib/common/response-content-matcher.d.ts.map +1 -1
  91. package/lib/common/response-content-matcher.js +14 -3
  92. package/lib/common/response-content-matcher.js.map +1 -1
  93. package/package.json +11 -11
  94. package/src/browser/ai-chat-frontend-contribution.ts +49 -0
  95. package/src/browser/ai-chat-frontend-module.ts +25 -4
  96. package/src/browser/change-set-decorator-service.ts +72 -0
  97. package/src/browser/change-set-file-element.ts +18 -13
  98. package/src/browser/change-set-file-resource.ts +1 -138
  99. package/src/browser/change-set-variable.ts +14 -6
  100. package/src/browser/context-file-variable-label-provider.ts +1 -1
  101. package/src/browser/file-chat-variable-contribution.ts +26 -29
  102. package/src/browser/task-context-service.ts +144 -0
  103. package/src/browser/task-context-storage-service.ts +75 -0
  104. package/src/browser/task-context-variable-contribution.ts +93 -0
  105. package/src/browser/task-context-variable-label-provider.ts +67 -0
  106. package/src/browser/task-context-variable.ts +28 -0
  107. package/src/common/chat-agents.ts +38 -22
  108. package/src/common/chat-model.ts +566 -17
  109. package/src/common/chat-request-parser.ts +2 -2
  110. package/src/common/chat-service.ts +17 -26
  111. package/src/common/chat-session-naming-service.ts +13 -15
  112. package/src/common/chat-session-summary-agent-prompt.ts +28 -0
  113. package/src/common/chat-session-summary-agent.ts +42 -0
  114. package/src/common/context-summary-variable.ts +1 -1
  115. package/src/common/parse-contents-with-incomplete-parts.spec.ts +114 -0
  116. package/src/common/parse-contents.spec.ts +24 -12
  117. package/src/common/parse-contents.ts +52 -6
  118. package/src/common/response-content-matcher.ts +21 -3
@@ -55,7 +55,7 @@ function offsetRange(start: number, endExclusive: number): OffsetRange {
55
55
  return { start, endExclusive };
56
56
  }
57
57
  @injectable()
58
- export class ChatRequestParserImpl {
58
+ export class ChatRequestParserImpl implements ChatRequestParser {
59
59
  constructor(
60
60
  @inject(ChatAgentService) private readonly agentService: ChatAgentService,
61
61
  @inject(AIVariableService) private readonly variableService: AIVariableService,
@@ -90,7 +90,7 @@ export class ChatRequestParserImpl {
90
90
  }
91
91
 
92
92
  // Get resolved variables from variable cache after all variables have been resolved.
93
- // We want to return all recursilvely resolved variables, thus use the whole cache.
93
+ // We want to return all recursively resolved variables, thus use the whole cache.
94
94
  const resolvedVariables = await getAllResolvedAIVariables(variableCache);
95
95
 
96
96
  return { request, parts, toolRequests, variables: resolvedVariables };
@@ -38,6 +38,7 @@ import {
38
38
  import { ChatRequestParser } from './chat-request-parser';
39
39
  import { ParsedChatRequest, ParsedChatRequestAgentPart } from './parsed-chat-request';
40
40
  import { ChatSessionNamingService } from './chat-session-naming-service';
41
+ import { Deferred } from '@theia/core/lib/common/promise-util';
41
42
 
42
43
  export interface ChatRequestInvocation {
43
44
  /**
@@ -125,6 +126,7 @@ export interface ChatService {
125
126
  getSessions(): ChatSession[];
126
127
  createSession(location?: ChatAgentLocation, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession;
127
128
  deleteSession(sessionId: string): void;
129
+ getActiveSession(): ChatSession | undefined;
128
130
  setActiveSession(sessionId: string, options?: SessionOptions): void;
129
131
 
130
132
  sendRequest(
@@ -208,6 +210,12 @@ export class ChatServiceImpl implements ChatService {
208
210
  this.onSessionEventEmitter.fire({ type: 'deleted', sessionId: sessionId });
209
211
  }
210
212
 
213
+ getActiveSession(): ChatSession | undefined {
214
+ const activeSessions = this._sessions.filter(candidate => candidate.isActive);
215
+ if (activeSessions.length > 1) { throw new Error('More than one session marked as active. This indicates an error in ChatService.'); }
216
+ return activeSessions.at(0);
217
+ }
218
+
211
219
  setActiveSession(sessionId: string | undefined, options?: SessionOptions): void {
212
220
  this._sessions.forEach(session => {
213
221
  session.isActive = session.id === sessionId;
@@ -225,7 +233,7 @@ export class ChatServiceImpl implements ChatService {
225
233
  }
226
234
 
227
235
  const resolutionContext: ChatSessionContext = { model: session.model };
228
- const resolvedContext = await this.resolveChatContext(session.model.context.getVariables(), resolutionContext);
236
+ const resolvedContext = await this.resolveChatContext(request.variables ?? session.model.context.getVariables(), resolutionContext);
229
237
  const parsedRequest = await this.chatRequestParser.parseChatRequest(request, session.model.location, resolvedContext);
230
238
  const agent = this.getAgent(parsedRequest, session);
231
239
 
@@ -244,33 +252,23 @@ export class ChatServiceImpl implements ChatService {
244
252
  this.updateSessionMetadata(session, requestModel);
245
253
  resolutionContext.request = requestModel;
246
254
 
247
- let resolveResponseCreated: (responseModel: ChatResponseModel) => void;
248
- let resolveResponseCompleted: (responseModel: ChatResponseModel) => void;
255
+ const responseCompletionDeferred = new Deferred<ChatResponseModel>();
249
256
  const invocation: ChatRequestInvocation = {
250
257
  requestCompleted: Promise.resolve(requestModel),
251
- responseCreated: new Promise(resolve => {
252
- resolveResponseCreated = resolve;
253
- }),
254
- responseCompleted: new Promise(resolve => {
255
- resolveResponseCompleted = resolve;
256
- }),
258
+ responseCreated: Promise.resolve(requestModel.response),
259
+ responseCompleted: responseCompletionDeferred.promise,
257
260
  };
258
261
 
259
- resolveResponseCreated!(requestModel.response);
260
262
  requestModel.response.onDidChange(() => {
261
263
  if (requestModel.response.isComplete) {
262
- resolveResponseCompleted!(requestModel.response);
264
+ responseCompletionDeferred.resolve(requestModel.response);
263
265
  }
264
266
  if (requestModel.response.isError) {
265
- resolveResponseCompleted!(requestModel.response);
267
+ responseCompletionDeferred.resolve(requestModel.response);
266
268
  }
267
269
  });
268
270
 
269
- if (agent) {
270
- agent.invoke(requestModel).catch(error => requestModel.response.error(error));
271
- } else {
272
- this.logger.error('No ChatAgents available to handle request!', requestModel);
273
- }
271
+ agent.invoke(requestModel).catch(error => requestModel.response.error(error));
274
272
 
275
273
  return invocation;
276
274
  }
@@ -304,15 +302,8 @@ export class ChatServiceImpl implements ChatService {
304
302
  context: ChatSessionContext,
305
303
  ): Promise<ChatContext> {
306
304
  // TODO use a common cache to resolve variables and return recursively resolved variables?
307
- const resolvedVariables = await Promise.all(
308
- resolutionRequests.map(async contextVariable => {
309
- const resolvedVariable = await this.variableService.resolveVariable(contextVariable, context);
310
- if (ResolvedAIContextVariable.is(resolvedVariable)) {
311
- return resolvedVariable;
312
- }
313
- return undefined;
314
- })
315
- ).then(results => results.filter((result): result is ResolvedAIContextVariable => result !== undefined));
305
+ const resolvedVariables = await Promise.all(resolutionRequests.map(async contextVariable => this.variableService.resolveVariable(contextVariable, context)))
306
+ .then(results => results.filter(ResolvedAIContextVariable.is));
316
307
  return { variables: resolvedVariables };
317
308
  }
318
309
 
@@ -18,12 +18,13 @@ import {
18
18
  Agent,
19
19
  AgentService,
20
20
  CommunicationRecordingService,
21
+ CommunicationRequestEntryParam,
21
22
  getTextOfResponse,
22
23
  LanguageModelRegistry,
23
- LanguageModelRequest,
24
24
  LanguageModelRequirement,
25
25
  PromptService,
26
- PromptTemplate
26
+ PromptTemplate,
27
+ UserRequest
27
28
  } from '@theia/ai-core';
28
29
  import { inject, injectable } from '@theia/core/shared/inversify';
29
30
  import { ChatSession } from './chat-service';
@@ -38,7 +39,7 @@ const CHAT_SESSION_NAMING_PROMPT = {
38
39
  'Use the same language for the chat conversation name as used in the provided conversation, if in doubt default to English. ' +
39
40
  'Start the chat conversation name with an upper-case letter. ' +
40
41
  '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' +
41
- 'IMPORTANT: Your answer MUST ONLY CONTAIN THE PROPOSED NAME and must not be preceded or succeeded with any other text.' +
42
+ 'IMPORTANT: Your answer MUST ONLY CONTAIN THE PROPOSED NAME and must not be preceded or followed by any other text.' +
42
43
  '\n\nOther session names:\n{{listOfSessionNames}}' +
43
44
  '\n\nConversation:\n{{conversation}}',
44
45
  };
@@ -92,7 +93,7 @@ export class ChatSessionNamingAgent implements Agent {
92
93
  }
93
94
 
94
95
  const conversation = chatSession.model.getRequests()
95
- .map(req => `<user>${req.request.text}</user>` +
96
+ .map(req => `<user>${req.message.parts.map(chunk => chunk.promptText).join('')}</user>` +
96
97
  (req.response.response ? `<assistant>${req.response.response.asString()}</assistant>` : ''))
97
98
  .join('\n\n');
98
99
  const listOfSessionNames = otherNames.map(name => name).join(', ');
@@ -103,22 +104,19 @@ export class ChatSessionNamingAgent implements Agent {
103
104
  throw new Error('Unable to create prompt message for generating chat session name');
104
105
  }
105
106
 
106
- const request: LanguageModelRequest = {
107
+ const sessionId = generateUuid();
108
+ const requestId = generateUuid();
109
+ const request: UserRequest = {
107
110
  messages: [{
108
111
  actor: 'user',
109
112
  text: message,
110
113
  type: 'text'
111
- }]
112
- };
113
-
114
- const sessionId = generateUuid();
115
- const requestId = generateUuid();
116
- this.recordingService.recordRequest({
117
- agentId: this.id,
118
- sessionId,
114
+ }],
119
115
  requestId,
120
- ...request
121
- });
116
+ sessionId,
117
+ agentId: this.id
118
+ };
119
+ this.recordingService.recordRequest({ ...request, request: request.messages } satisfies CommunicationRequestEntryParam);
122
120
 
123
121
  const result = await lm.request(request);
124
122
  const response = await getTextOfResponse(result);
@@ -0,0 +1,28 @@
1
+ /* eslint-disable @typescript-eslint/tslint/config */
2
+ // *****************************************************************************
3
+ // Copyright (C) 2025 EclipseSource GmbH and others.
4
+ //
5
+ // This file is licensed under the MIT License.
6
+ // See LICENSE-MIT.txt in the project root for license information.
7
+ // https://opensource.org/license/mit.
8
+ //
9
+ // SPDX-License-Identifier: MIT
10
+
11
+ import { CHANGE_SET_SUMMARY_VARIABLE_ID } from './context-variables';
12
+
13
+ export const CHAT_SESSION_SUMMARY_PROMPT = {
14
+ id: 'chat-session-summary-prompt',
15
+ template: `{{!-- !-- This prompt is licensed under the MIT License (https://opensource.org/license/mit).
16
+ Made improvements or adaptations to this prompt template? We\'d love for you to share it with the community! Contribute back here: ' +
17
+ 'https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}}\n\n' +
18
+ 'You are a chat agent for summarizing AI agent chat sessions for later use. \
19
+ Review the conversation above and generate a concise summary that captures every crucial detail, \
20
+ including all requirements, decisions, and pending tasks. \
21
+ 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 \
22
+ response for use by AI agents. \
23
+ Also consider the system message.
24
+ Make sure you include all necessary context information and use unique references (such as URIs, file paths, etc.).
25
+ If the conversation was about a task, describe the state of the task, i.e. what has been completed and what is open.
26
+ If a changeset is open in the session, describe the state of the suggested changes.
27
+ {{${CHANGE_SET_SUMMARY_VARIABLE_ID}}}`,
28
+ };
@@ -0,0 +1,42 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2025 EclipseSource GmbH and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import {
18
+ LanguageModelRequirement,
19
+ PromptTemplate
20
+ } from '@theia/ai-core';
21
+ import { injectable } from '@theia/core/shared/inversify';
22
+ import { AbstractStreamParsingChatAgent, ChatAgent } from './chat-agents';
23
+ import { CHAT_SESSION_SUMMARY_PROMPT } from './chat-session-summary-agent-prompt';
24
+
25
+ @injectable()
26
+ export class ChatSessionSummaryAgent extends AbstractStreamParsingChatAgent implements ChatAgent {
27
+ static ID = 'chat-session-summary-agent';
28
+ id = ChatSessionSummaryAgent.ID;
29
+ name = 'Chat Session Summary';
30
+ override description = 'Agent for generating chat session summaries.';
31
+ override variables = [];
32
+ override promptTemplates: PromptTemplate[] = [CHAT_SESSION_SUMMARY_PROMPT];
33
+ protected readonly defaultLanguageModelPurpose = 'chat-session-summary';
34
+ languageModelRequirements: LanguageModelRequirement[] = [{
35
+ purpose: 'chat-session-summary',
36
+ identifier: 'openai/gpt-4o-mini',
37
+ }];
38
+ override agentSpecificVariables = [];
39
+ override functions = [];
40
+ override locations = [];
41
+ override tags = [];
42
+ }
@@ -38,7 +38,7 @@ export class ContextSummaryVariableContribution implements AIVariableContributio
38
38
 
39
39
  async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise<ResolvedAIVariable | undefined> {
40
40
  if (!ChatSessionContext.is(context) || request.variable.name !== CONTEXT_SUMMARY_VARIABLE.name) { return undefined; }
41
- const data = context.model.context.getVariables().filter(variable => variable.variable.isContextVariable)
41
+ const data = ChatSessionContext.getVariables(context).filter(variable => variable.variable.isContextVariable)
42
42
  .map(variable => ({
43
43
  type: variable.variable.name,
44
44
  // eslint-disable-next-line no-null/no-null
@@ -0,0 +1,114 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2025 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import { expect } from 'chai';
18
+ import { MutableChatRequestModel, CodeChatResponseContentImpl, MarkdownChatResponseContentImpl } from './chat-model';
19
+ import { parseContents } from './parse-contents';
20
+ import { ResponseContentMatcher } from './response-content-matcher';
21
+
22
+ const fakeRequest = {} as MutableChatRequestModel;
23
+
24
+ // Custom matchers with incompleteContentFactory for testing
25
+ const TestCodeContentMatcher: ResponseContentMatcher = {
26
+ start: /^```.*?$/m,
27
+ end: /^```$/m,
28
+ contentFactory: (content: string) => {
29
+ const language = content.match(/^```(\w+)/)?.[1] || '';
30
+ const code = content.replace(/^```(\w+)\n|```$/g, '');
31
+ return new CodeChatResponseContentImpl(code.trim(), language);
32
+ },
33
+ incompleteContentFactory: (content: string) => {
34
+ const language = content.match(/^```(\w+)/)?.[1] || '';
35
+ // Remove only the start delimiter, since we don't have an end delimiter yet
36
+ const code = content.replace(/^```(\w+)\n?/g, '');
37
+ return new CodeChatResponseContentImpl(code.trim(), language);
38
+ }
39
+ };
40
+
41
+ describe('parseContents with incomplete parts', () => {
42
+ it('should handle incomplete code blocks with incompleteContentFactory', () => {
43
+ // Only the start of a code block without an end
44
+ const text = '```typescript\nconsole.log("Hello World");';
45
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
46
+
47
+ expect(result.length).to.equal(1);
48
+ expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
49
+ const codeContent = result[0] as CodeChatResponseContentImpl;
50
+ expect(codeContent.code).to.equal('console.log("Hello World");');
51
+ expect(codeContent.language).to.equal('typescript');
52
+ });
53
+
54
+ it('should handle complete code blocks with contentFactory', () => {
55
+ const text = '```typescript\nconsole.log("Hello World");\n```';
56
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
57
+
58
+ expect(result.length).to.equal(1);
59
+ expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
60
+ const codeContent = result[0] as CodeChatResponseContentImpl;
61
+ expect(codeContent.code).to.equal('console.log("Hello World");');
62
+ expect(codeContent.language).to.equal('typescript');
63
+ });
64
+
65
+ it('should handle mixed content with incomplete and complete blocks', () => {
66
+ const text = 'Some text\n```typescript\nconsole.log("Hello");\n```\nMore text\n```python\nprint("World")';
67
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
68
+
69
+ expect(result.length).to.equal(4);
70
+ expect(result[0]).to.be.instanceOf(MarkdownChatResponseContentImpl);
71
+ expect(result[1]).to.be.instanceOf(CodeChatResponseContentImpl);
72
+ const completeContent = result[1] as CodeChatResponseContentImpl;
73
+ expect(completeContent.language).to.equal('typescript');
74
+ expect(result[2]).to.be.instanceOf(MarkdownChatResponseContentImpl);
75
+ expect(result[3]).to.be.instanceOf(CodeChatResponseContentImpl);
76
+ const incompleteContent = result[3] as CodeChatResponseContentImpl;
77
+ expect(incompleteContent.language).to.equal('python');
78
+ });
79
+
80
+ it('should use default content factory if no incompleteContentFactory provided', () => {
81
+ // Create a matcher without incompleteContentFactory
82
+ const matcherWithoutIncomplete: ResponseContentMatcher = {
83
+ start: /^<test>$/m,
84
+ end: /^<\/test>$/m,
85
+ contentFactory: (content: string) => new MarkdownChatResponseContentImpl('complete: ' + content)
86
+ };
87
+
88
+ // Text with only the start delimiter
89
+ const text = '<test>\ntest content';
90
+ const result = parseContents(text, fakeRequest, [matcherWithoutIncomplete]);
91
+
92
+ expect(result.length).to.equal(1);
93
+ expect(result[0]).to.be.instanceOf(MarkdownChatResponseContentImpl);
94
+ expect((result[0] as MarkdownChatResponseContentImpl).content.value).to.equal('<test>\ntest content');
95
+ });
96
+
97
+ it('should prefer complete matches over incomplete ones', () => {
98
+ // Text with both a complete and incomplete match at same position
99
+ const text = '```typescript\nconsole.log();\n```\n<test>\ntest content';
100
+ const matcherWithoutIncomplete: ResponseContentMatcher = {
101
+ start: /^<test>$/m,
102
+ end: /^<\/test>$/m,
103
+ contentFactory: (content: string) => new MarkdownChatResponseContentImpl('complete: ' + content)
104
+ };
105
+
106
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher, matcherWithoutIncomplete]);
107
+
108
+ expect(result.length).to.equal(2);
109
+ expect(result[0]).to.be.instanceOf(CodeChatResponseContentImpl);
110
+ expect((result[0] as CodeChatResponseContentImpl).language).to.equal('typescript');
111
+ expect(result[1]).to.be.instanceOf(MarkdownChatResponseContentImpl);
112
+ expect((result[1] as MarkdownChatResponseContentImpl).content.value).to.contain('test content');
113
+ });
114
+ });
@@ -19,6 +19,16 @@ import { MutableChatRequestModel, ChatResponseContent, CodeChatResponseContentIm
19
19
  import { parseContents } from './parse-contents';
20
20
  import { CodeContentMatcher, ResponseContentMatcher } from './response-content-matcher';
21
21
 
22
+ export const TestCodeContentMatcher: ResponseContentMatcher = {
23
+ start: /^```.*?$/m,
24
+ end: /^```$/m,
25
+ contentFactory: (content: string) => {
26
+ const language = content.match(/^```(\w+)/)?.[1] || '';
27
+ const code = content.replace(/^```(\w+)\n|```$/g, '');
28
+ return new CodeChatResponseContentImpl(code.trim(), language);
29
+ }
30
+ };
31
+
22
32
  export class CommandChatResponseContentImpl implements ChatResponseContent {
23
33
  constructor(public readonly command: string) { }
24
34
  kind = 'command';
@@ -38,19 +48,19 @@ const fakeRequest = {} as MutableChatRequestModel;
38
48
  describe('parseContents', () => {
39
49
  it('should parse code content', () => {
40
50
  const text = '```typescript\nconsole.log("Hello World");\n```';
41
- const result = parseContents(text, fakeRequest);
51
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
42
52
  expect(result).to.deep.equal([new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript')]);
43
53
  });
44
54
 
45
55
  it('should parse markdown content', () => {
46
56
  const text = 'Hello **World**';
47
- const result = parseContents(text, fakeRequest);
57
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
48
58
  expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('Hello **World**')]);
49
59
  });
50
60
 
51
61
  it('should parse multiple content blocks', () => {
52
62
  const text = '```typescript\nconsole.log("Hello World");\n```\nHello **World**';
53
- const result = parseContents(text, fakeRequest);
63
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
54
64
  expect(result).to.deep.equal([
55
65
  new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
56
66
  new MarkdownChatResponseContentImpl('\nHello **World**')
@@ -59,7 +69,7 @@ describe('parseContents', () => {
59
69
 
60
70
  it('should parse multiple content blocks with different languages', () => {
61
71
  const text = '```typescript\nconsole.log("Hello World");\n```\n```python\nprint("Hello World")\n```';
62
- const result = parseContents(text, fakeRequest);
72
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
63
73
  expect(result).to.deep.equal([
64
74
  new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
65
75
  new CodeChatResponseContentImpl('print("Hello World")', 'python')
@@ -68,7 +78,7 @@ describe('parseContents', () => {
68
78
 
69
79
  it('should parse multiple content blocks with different languages and markdown', () => {
70
80
  const text = '```typescript\nconsole.log("Hello World");\n```\nHello **World**\n```python\nprint("Hello World")\n```';
71
- const result = parseContents(text, fakeRequest);
81
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
72
82
  expect(result).to.deep.equal([
73
83
  new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
74
84
  new MarkdownChatResponseContentImpl('\nHello **World**\n'),
@@ -78,7 +88,7 @@ describe('parseContents', () => {
78
88
 
79
89
  it('should parse content blocks with empty content', () => {
80
90
  const text = '```typescript\n```\nHello **World**\n```python\nprint("Hello World")\n```';
81
- const result = parseContents(text, fakeRequest);
91
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
82
92
  expect(result).to.deep.equal([
83
93
  new CodeChatResponseContentImpl('', 'typescript'),
84
94
  new MarkdownChatResponseContentImpl('\nHello **World**\n'),
@@ -88,7 +98,7 @@ describe('parseContents', () => {
88
98
 
89
99
  it('should parse content with markdown, code, and markdown', () => {
90
100
  const text = 'Hello **World**\n```typescript\nconsole.log("Hello World");\n```\nGoodbye **World**';
91
- const result = parseContents(text, fakeRequest);
101
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
92
102
  expect(result).to.deep.equal([
93
103
  new MarkdownChatResponseContentImpl('Hello **World**\n'),
94
104
  new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
@@ -98,34 +108,36 @@ describe('parseContents', () => {
98
108
 
99
109
  it('should handle text with no special content', () => {
100
110
  const text = 'Just some plain text.';
101
- const result = parseContents(text, fakeRequest);
111
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
102
112
  expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('Just some plain text.')]);
103
113
  });
104
114
 
105
115
  it('should handle text with only start code block', () => {
106
116
  const text = '```typescript\nconsole.log("Hello World");';
117
+ // We're using the standard CodeContentMatcher which has incompleteContentFactory
107
118
  const result = parseContents(text, fakeRequest);
108
- expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('```typescript\nconsole.log("Hello World");')]);
119
+ expect(result).to.deep.equal([new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript')]);
109
120
  });
110
121
 
111
122
  it('should handle text with only end code block', () => {
112
123
  const text = 'console.log("Hello World");\n```';
113
- const result = parseContents(text, fakeRequest);
124
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
114
125
  expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('console.log("Hello World");\n```')]);
115
126
  });
116
127
 
117
128
  it('should handle text with unmatched code block', () => {
118
129
  const text = '```typescript\nconsole.log("Hello World");\n```\n```python\nprint("Hello World")';
130
+ // We're using the standard CodeContentMatcher which has incompleteContentFactory
119
131
  const result = parseContents(text, fakeRequest);
120
132
  expect(result).to.deep.equal([
121
133
  new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'),
122
- new MarkdownChatResponseContentImpl('\n```python\nprint("Hello World")')
134
+ new CodeChatResponseContentImpl('print("Hello World")', 'python')
123
135
  ]);
124
136
  });
125
137
 
126
138
  it('should parse code block without newline after language', () => {
127
139
  const text = '```typescript console.log("Hello World");```';
128
- const result = parseContents(text, fakeRequest);
140
+ const result = parseContents(text, fakeRequest, [TestCodeContentMatcher]);
129
141
  expect(result).to.deep.equal([
130
142
  new MarkdownChatResponseContentImpl('```typescript console.log("Hello World");```')
131
143
  ]);
@@ -20,6 +20,7 @@ interface Match {
20
20
  matcher: ResponseContentMatcher;
21
21
  index: number;
22
22
  content: string;
23
+ isComplete: boolean;
23
24
  }
24
25
 
25
26
  export function parseContents(
@@ -50,7 +51,16 @@ export function parseContents(
50
51
  }
51
52
  }
52
53
  // 2. Add the matched content object
53
- result.push(match.matcher.contentFactory(match.content, request));
54
+ if (match.isComplete) {
55
+ // Complete match, use regular content factory
56
+ result.push(match.matcher.contentFactory(match.content, request));
57
+ } else if (match.matcher.incompleteContentFactory) {
58
+ // Incomplete match with an incomplete content factory available
59
+ result.push(match.matcher.incompleteContentFactory(match.content, request));
60
+ } else {
61
+ // Incomplete match but no incomplete content factory available, use default
62
+ result.push(defaultContentFactory(match.content, request));
63
+ }
54
64
  // Update currentIndex to the end of the end of the match
55
65
  // And continue with the search after the end of the match
56
66
  currentIndex += match.index + match.content.length;
@@ -60,7 +70,9 @@ export function parseContents(
60
70
  }
61
71
 
62
72
  export function findFirstMatch(contentMatchers: ResponseContentMatcher[], text: string): Match | undefined {
63
- let firstMatch: { matcher: ResponseContentMatcher, index: number, content: string } | undefined;
73
+ let firstMatch: Match | undefined;
74
+ let firstIncompleteMatch: Match | undefined;
75
+
64
76
  for (const matcher of contentMatchers) {
65
77
  const startMatch = matcher.start.exec(text);
66
78
  if (!startMatch) {
@@ -70,24 +82,58 @@ export function findFirstMatch(contentMatchers: ResponseContentMatcher[], text:
70
82
  const endOfStartMatch = startMatch.index + startMatch[0].length;
71
83
  if (endOfStartMatch >= text.length) {
72
84
  // There is no text after the start match.
73
- // No need to search for the end match yet, try next matcher.
85
+ // This is an incomplete match if the matcher has an incompleteContentFactory
86
+ if (matcher.incompleteContentFactory) {
87
+ const incompleteMatch: Match = {
88
+ matcher,
89
+ index: startMatch.index,
90
+ content: text.substring(startMatch.index),
91
+ isComplete: false
92
+ };
93
+ if (!firstIncompleteMatch || incompleteMatch.index < firstIncompleteMatch.index) {
94
+ firstIncompleteMatch = incompleteMatch;
95
+ }
96
+ }
74
97
  continue;
75
98
  }
99
+
76
100
  const remainingTextAfterStartMatch = text.substring(endOfStartMatch);
77
101
  const endMatch = matcher.end.exec(remainingTextAfterStartMatch);
102
+
78
103
  if (!endMatch) {
79
- // No end match found, try next matcher.
104
+ // No end match found, this is an incomplete match
105
+ if (matcher.incompleteContentFactory) {
106
+ const incompleteMatch: Match = {
107
+ matcher,
108
+ index: startMatch.index,
109
+ content: text.substring(startMatch.index),
110
+ isComplete: false
111
+ };
112
+ if (!firstIncompleteMatch || incompleteMatch.index < firstIncompleteMatch.index) {
113
+ firstIncompleteMatch = incompleteMatch;
114
+ }
115
+ }
80
116
  continue;
81
117
  }
118
+
82
119
  // Found start and end match.
83
120
  // Record the full match, if it is the earliest found so far.
84
121
  const index = startMatch.index;
85
122
  const contentEnd = index + startMatch[0].length + endMatch.index + endMatch[0].length;
86
123
  const content = text.substring(index, contentEnd);
124
+ const completeMatch: Match = { matcher, index, content, isComplete: true };
125
+
87
126
  if (!firstMatch || index < firstMatch.index) {
88
- firstMatch = { matcher, index, content };
127
+ firstMatch = completeMatch;
89
128
  }
90
129
  }
91
- return firstMatch;
130
+
131
+ // If we found a complete match, return it
132
+ if (firstMatch) {
133
+ return firstMatch;
134
+ }
135
+
136
+ // Otherwise, return the first incomplete match if one exists
137
+ return firstIncompleteMatch;
92
138
  }
93
139
 
@@ -23,7 +23,7 @@ import { injectable } from '@theia/core/shared/inversify';
23
23
 
24
24
  export type ResponseContentFactory = (content: string, request: MutableChatRequestModel) => ChatResponseContent;
25
25
 
26
- export const MarkdownContentFactory: ResponseContentFactory = (content: string) =>
26
+ export const MarkdownContentFactory: ResponseContentFactory = (content: string, request: MutableChatRequestModel) =>
27
27
  new MarkdownChatResponseContentImpl(content);
28
28
 
29
29
  /**
@@ -53,14 +53,32 @@ export interface ResponseContentMatcher {
53
53
  * from start index to end index of the match (including delimiters).
54
54
  */
55
55
  contentFactory: ResponseContentFactory;
56
+ /**
57
+ * Optional factory for creating a response content when only the start delimiter has been matched,
58
+ * but not yet the end delimiter. Used during streaming to provide better visual feedback.
59
+ * If not provided, the default content factory will be used until the end delimiter is matched.
60
+ */
61
+ incompleteContentFactory?: ResponseContentFactory;
56
62
  }
57
63
 
58
64
  export const CodeContentMatcher: ResponseContentMatcher = {
59
- start: /^```.*?$/m,
65
+ // Only match when we have the complete first line ending with a newline
66
+ // This ensures we have the full language specification before creating the editor
67
+ start: /^```.*\n/m,
60
68
  end: /^```$/m,
61
- contentFactory: (content: string) => {
69
+ contentFactory: (content: string, request: MutableChatRequestModel) => {
62
70
  const language = content.match(/^```(\w+)/)?.[1] || '';
63
71
  const code = content.replace(/^```(\w+)\n|```$/g, '');
72
+ return new CodeChatResponseContentImpl(code.trim(), language);
73
+ },
74
+ incompleteContentFactory: (content: string, request: MutableChatRequestModel) => {
75
+ // By this point, we know we have at least the complete first line with ```
76
+ const firstLine = content.split('\n')[0];
77
+ const language = firstLine.match(/^```(\w+)/)?.[1] || '';
78
+
79
+ // Remove the first line to get just the code content
80
+ const code = content.substring(content.indexOf('\n') + 1);
81
+
64
82
  return new CodeChatResponseContentImpl(code.trim(), language);
65
83
  }
66
84
  };