@theia/ai-chat 1.74.0 → 1.74.1
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.
- package/lib/browser/chat-request-command-resolution.spec.d.ts +2 -0
- package/lib/browser/chat-request-command-resolution.spec.d.ts.map +1 -0
- package/lib/browser/chat-request-command-resolution.spec.js +94 -0
- package/lib/browser/chat-request-command-resolution.spec.js.map +1 -0
- package/lib/common/chat-model.d.ts +1 -0
- package/lib/common/chat-model.d.ts.map +1 -1
- package/lib/common/chat-model.js +8 -1
- package/lib/common/chat-model.js.map +1 -1
- package/lib/common/chat-request-parser.d.ts +7 -2
- package/lib/common/chat-request-parser.d.ts.map +1 -1
- package/lib/common/chat-request-parser.js +25 -14
- package/lib/common/chat-request-parser.js.map +1 -1
- package/lib/common/chat-request-parser.spec.js +288 -14
- package/lib/common/chat-request-parser.spec.js.map +1 -1
- package/lib/common/chat-response-model.spec.js +31 -0
- package/lib/common/chat-response-model.spec.js.map +1 -1
- package/package.json +10 -10
- package/src/browser/chat-request-command-resolution.spec.ts +117 -0
- package/src/common/chat-model.ts +8 -1
- package/src/common/chat-request-parser.spec.ts +348 -16
- package/src/common/chat-request-parser.ts +28 -18
- package/src/common/chat-response-model.spec.ts +41 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom';
|
|
18
|
+
|
|
19
|
+
let disableJSDOM = enableJSDOM();
|
|
20
|
+
|
|
21
|
+
import 'reflect-metadata';
|
|
22
|
+
|
|
23
|
+
import { expect } from 'chai';
|
|
24
|
+
import * as sinon from 'sinon';
|
|
25
|
+
import { CommandService, ILogger, Logger } from '@theia/core';
|
|
26
|
+
import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
|
|
27
|
+
import { DefaultAIVariableService, PromptService, PromptServiceImpl, ToolInvocationRegistryImpl } from '@theia/ai-core';
|
|
28
|
+
import { PromptVariableContribution } from '@theia/ai-core/lib/browser/prompt-variable-contribution';
|
|
29
|
+
import { ChatAgentServiceImpl } from '../common/chat-agent-service';
|
|
30
|
+
import { ChatAgentLocation } from '../common/chat-agents';
|
|
31
|
+
import { ChatRequestParserImpl } from '../common/chat-request-parser';
|
|
32
|
+
|
|
33
|
+
disableJSDOM();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* End-to-end coverage for slash commands: a request is parsed into parts and those parts are
|
|
37
|
+
* resolved by the real prompt variable resolver, so that the text finally sent to the language
|
|
38
|
+
* model is asserted rather than just the intermediate `#prompt:command|args` representation.
|
|
39
|
+
*/
|
|
40
|
+
describe('slash command parsing and resolution', () => {
|
|
41
|
+
before(() => disableJSDOM = enableJSDOM());
|
|
42
|
+
after(() => disableJSDOM());
|
|
43
|
+
|
|
44
|
+
let promptService: PromptService;
|
|
45
|
+
let parser: ChatRequestParserImpl;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
promptService = new PromptServiceImpl();
|
|
49
|
+
promptService.addBuiltInPromptFragment({
|
|
50
|
+
id: 'command-hello',
|
|
51
|
+
template: 'Greet $ARGUMENTS warmly.',
|
|
52
|
+
isCommand: true,
|
|
53
|
+
commandName: 'hello'
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const variableService = new DefaultAIVariableService({ getContributions: () => [] });
|
|
57
|
+
(variableService as unknown as Record<string, unknown>)['logger'] = new MockLogger();
|
|
58
|
+
|
|
59
|
+
const promptVariableContribution = new PromptVariableContribution();
|
|
60
|
+
(promptVariableContribution as unknown as Record<string, unknown>)['promptService'] = promptService;
|
|
61
|
+
(promptVariableContribution as unknown as Record<string, unknown>)['logger'] = new MockLogger();
|
|
62
|
+
(promptVariableContribution as unknown as Record<string, unknown>)['commandService'] = {} as CommandService;
|
|
63
|
+
promptVariableContribution.registerVariables(variableService);
|
|
64
|
+
|
|
65
|
+
parser = new ChatRequestParserImpl(
|
|
66
|
+
sinon.createStubInstance(ChatAgentServiceImpl),
|
|
67
|
+
variableService,
|
|
68
|
+
sinon.createStubInstance(ToolInvocationRegistryImpl),
|
|
69
|
+
sinon.createStubInstance(Logger) as ILogger
|
|
70
|
+
);
|
|
71
|
+
(parser as unknown as { promptService: PromptService }).promptService = promptService;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** The text that is finally sent to the language model. */
|
|
75
|
+
const resolve = async (text: string): Promise<string> => {
|
|
76
|
+
const parsed = await parser.parseChatRequest({ text }, ChatAgentLocation.Panel, { variables: [] });
|
|
77
|
+
return parsed.parts.map(part => part.promptText).join('');
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
it('resolves a single command with an argument', async () => {
|
|
81
|
+
expect(await resolve('/hello Klaus')).to.equal('Greet Klaus warmly.');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('resolves the same command twice on one line, keeping the arguments apart', async () => {
|
|
85
|
+
expect(await resolve('/hello Klaus /hello Maria')).to.equal('Greet Klaus warmly. Greet Maria warmly.');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('resolves the same command on two lines, preserving the line break', async () => {
|
|
89
|
+
expect(await resolve('/hello Klaus\n/hello Maria')).to.equal('Greet Klaus warmly.\nGreet Maria warmly.');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('resolves a command repeated three times', async () => {
|
|
93
|
+
expect(await resolve('/hello Klaus /hello Maria /hello Bob')).to.equal('Greet Klaus warmly. Greet Maria warmly. Greet Bob warmly.');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does not let a command consume the next line', async () => {
|
|
97
|
+
expect(await resolve('/hello Klaus\nand please be brief')).to.equal('Greet Klaus warmly.\nand please be brief');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('keeps surrounding text around a command', async () => {
|
|
101
|
+
expect(await resolve('please /hello Klaus now')).to.equal('please Greet Klaus now warmly.');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not resolve a path as a command and keeps the message intact', async () => {
|
|
105
|
+
const text = 'please look at /home/user/notes.txt and fix the bug';
|
|
106
|
+
expect(await resolve(text)).to.equal(text);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('does not resolve an unknown command and keeps the message intact', async () => {
|
|
110
|
+
const text = '/goodbye Klaus';
|
|
111
|
+
expect(await resolve(text)).to.equal(text);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('uses a path argument of a known command without dropping it', async () => {
|
|
115
|
+
expect(await resolve('/hello /home/user/notes.txt')).to.equal('Greet /home/user/notes.txt warmly.');
|
|
116
|
+
});
|
|
117
|
+
});
|
package/src/common/chat-model.ts
CHANGED
|
@@ -3164,6 +3164,7 @@ class ChatResponseImpl implements ChatResponse {
|
|
|
3164
3164
|
protected _content: ChatResponseContent[];
|
|
3165
3165
|
protected _responseRepresentation: string;
|
|
3166
3166
|
protected _responseRepresentationForDisplay: string;
|
|
3167
|
+
protected readonly contentChangeListeners = new Map<ChatResponseContent, Disposable>();
|
|
3167
3168
|
|
|
3168
3169
|
constructor() {
|
|
3169
3170
|
this._content = [];
|
|
@@ -3174,6 +3175,8 @@ class ChatResponseImpl implements ChatResponse {
|
|
|
3174
3175
|
}
|
|
3175
3176
|
|
|
3176
3177
|
clearContent(): void {
|
|
3178
|
+
this.contentChangeListeners.forEach(listener => listener.dispose());
|
|
3179
|
+
this.contentChangeListeners.clear();
|
|
3177
3180
|
this._content = [];
|
|
3178
3181
|
this._updateResponseRepresentation();
|
|
3179
3182
|
this._onDidChangeEmitter.fire();
|
|
@@ -3209,7 +3212,11 @@ class ChatResponseImpl implements ChatResponse {
|
|
|
3209
3212
|
// Forward content-level change events (e.g. partial-result updates from a
|
|
3210
3213
|
// renderer) so auto-save can persist them. Without this, mutations that
|
|
3211
3214
|
// don't go through addContent/merge are invisible to listeners.
|
|
3212
|
-
|
|
3215
|
+
// The subscription is tracked so that clearContent() can dispose it: the stream
|
|
3216
|
+
// parser clears and re-adds the content per token, which would otherwise stack
|
|
3217
|
+
// up one listener per token on the same content object (#17858).
|
|
3218
|
+
this.contentChangeListeners.get(nextContent)?.dispose();
|
|
3219
|
+
this.contentChangeListeners.set(nextContent, nextContent.onDidChange(() => this._onDidChangeEmitter.fire()));
|
|
3213
3220
|
}
|
|
3214
3221
|
} else if (ServerToolCallChatResponseContent.is(nextContent) && nextContent.id !== undefined) {
|
|
3215
3222
|
// Server tool calls are matched by id (the start and result blocks arrive as separate stream parts).
|
|
@@ -26,11 +26,23 @@ import { ParsedChatRequestAgentPart, ParsedChatRequestFunctionPart, ParsedChatRe
|
|
|
26
26
|
import { AgentDelegationTool } from '../browser/agent-delegation-tool';
|
|
27
27
|
|
|
28
28
|
describe('ChatRequestParserImpl', () => {
|
|
29
|
+
/** Command names the stubbed `PromptService` knows about. Anything else is not a command. */
|
|
30
|
+
const KNOWN_COMMANDS = ['hello', 'explain', 'compare', 'cmd', 'summarize', 'skill-one', 'skill-two'];
|
|
31
|
+
/** Prompt fragments that are not marked as commands, but can still be invoked by their id. */
|
|
32
|
+
const KNOWN_FRAGMENT_IDS = ['coder-system'];
|
|
33
|
+
|
|
34
|
+
const promptService = {
|
|
35
|
+
isKnownCommand: (name: string) => KNOWN_COMMANDS.includes(name) || KNOWN_FRAGMENT_IDS.includes(name),
|
|
36
|
+
getCommands: () => KNOWN_COMMANDS.map(name => ({ id: `command-${name}`, template: '', isCommand: true, commandName: name }))
|
|
37
|
+
} as unknown as PromptService;
|
|
38
|
+
|
|
29
39
|
const chatAgentService = sinon.createStubInstance(ChatAgentServiceImpl);
|
|
30
40
|
const variableService = sinon.createStubInstance(DefaultAIVariableService);
|
|
31
41
|
const toolInvocationRegistry = sinon.createStubInstance(ToolInvocationRegistryImpl);
|
|
32
42
|
const logger: ILogger = sinon.createStubInstance(Logger);
|
|
33
43
|
const parser = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry, logger);
|
|
44
|
+
// The parser injects the PromptService as a property, so it has to be assigned manually here.
|
|
45
|
+
(parser as unknown as { promptService: PromptService }).promptService = promptService;
|
|
34
46
|
|
|
35
47
|
beforeEach(() => {
|
|
36
48
|
// Reset our stubs before each test
|
|
@@ -324,24 +336,16 @@ describe('ChatRequestParserImpl', () => {
|
|
|
324
336
|
expect(varPart.variableArg).to.equal('explain|/path/to/file');
|
|
325
337
|
});
|
|
326
338
|
|
|
327
|
-
it('
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
{ id: 'command-skill-two', template: '', isCommand: true, commandName: 'skill-two' },
|
|
332
|
-
]
|
|
333
|
-
} as unknown as PromptService;
|
|
334
|
-
const parserWithPromptService = new ChatRequestParserImpl(chatAgentService, variableService, toolInvocationRegistry, logger);
|
|
335
|
-
(parserWithPromptService as unknown as { promptService: PromptService }).promptService = promptService;
|
|
339
|
+
it('does not treat path segments as commands', async () => {
|
|
340
|
+
const req: ChatRequest = {
|
|
341
|
+
text: 'please look at /home/user/notes.txt and fix the bug'
|
|
342
|
+
};
|
|
336
343
|
const context: ChatContext = { variables: [] };
|
|
344
|
+
const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
|
|
337
345
|
|
|
338
|
-
|
|
339
|
-
expect(
|
|
340
|
-
expect(
|
|
341
|
-
|
|
342
|
-
const pathArgument = await parserWithPromptService.parseChatRequest({ text: '/skill-one /tmp' }, ChatAgentLocation.Panel, context);
|
|
343
|
-
expect(pathArgument.parts.length).to.equal(1);
|
|
344
|
-
expect((pathArgument.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one|/tmp');
|
|
346
|
+
expect(result.parts.length).to.equal(1);
|
|
347
|
+
expect(result.parts[0].kind).to.equal('text');
|
|
348
|
+
expect(result.parts[0].text).to.equal(req.text);
|
|
345
349
|
});
|
|
346
350
|
|
|
347
351
|
it('treats the first @agent mention as the selector and does not allow later mentions to override it', async () => {
|
|
@@ -525,6 +529,334 @@ describe('ChatRequestParserImpl', () => {
|
|
|
525
529
|
});
|
|
526
530
|
});
|
|
527
531
|
|
|
532
|
+
describe('slash command disambiguation', () => {
|
|
533
|
+
const parse = (text: string) => parser.parseChatRequest({ text }, ChatAgentLocation.Panel, { variables: [] });
|
|
534
|
+
|
|
535
|
+
/** Every character of the input has to be covered by exactly one part, in order and without gaps. */
|
|
536
|
+
const expectFullCoverage = (parts: ReadonlyArray<{ range: { start: number, endExclusive: number } }>, text: string): void => {
|
|
537
|
+
let expectedStart = 0;
|
|
538
|
+
for (const part of parts) {
|
|
539
|
+
expect(part.range.start, `part starting at ${part.range.start} does not continue at ${expectedStart}`).to.equal(expectedStart);
|
|
540
|
+
expectedStart = part.range.endExclusive;
|
|
541
|
+
}
|
|
542
|
+
expect(expectedStart, 'parts do not cover the whole message').to.equal(text.length);
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const expectPlainText = async (text: string) => {
|
|
546
|
+
const result = await parse(text);
|
|
547
|
+
expect(result.parts.map(p => p.kind), `expected only text parts for ${JSON.stringify(text)}`).to.deep.equal(['text']);
|
|
548
|
+
expect(result.parts[0].text).to.equal(text);
|
|
549
|
+
expectFullCoverage(result.parts, text);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
describe('unknown commands stay plain text', () => {
|
|
553
|
+
const unknownCommandInputs = [
|
|
554
|
+
'please look at /home/user/notes.txt and fix the bug',
|
|
555
|
+
'/home/user/notes.txt is broken',
|
|
556
|
+
'read /usr/local/bin/theia',
|
|
557
|
+
'the file is at /etc/hosts',
|
|
558
|
+
'compare /tmp/a.txt with /tmp/b.txt',
|
|
559
|
+
'run cd /home && ls',
|
|
560
|
+
'see /usr/lib/node/foo.js:12 for the stack trace',
|
|
561
|
+
'what does the option --foo /bar do?',
|
|
562
|
+
'/does-not-exist do something',
|
|
563
|
+
'/tmp',
|
|
564
|
+
'move it to /tmp',
|
|
565
|
+
'the separator is / on Unix'
|
|
566
|
+
];
|
|
567
|
+
|
|
568
|
+
unknownCommandInputs.forEach(text => {
|
|
569
|
+
it(`keeps ${JSON.stringify(text)} as plain text`, () => expectPlainText(text));
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('does not drop any user text when a path follows a known command name prefix', async () => {
|
|
573
|
+
// `/summarizes` is not a known command, even though `/summarize` is.
|
|
574
|
+
await expectPlainText('/summarizes the file');
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('does not treat a known command name as a command when it is a path segment', async () => {
|
|
578
|
+
// `summarize` is known, but `/summarize/foo` is a path, not a command invocation.
|
|
579
|
+
await expectPlainText('/summarize/foo is a path');
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('does not treat a known command name followed by punctuation as a command', async () => {
|
|
583
|
+
await expectPlainText('did you mean /summarize?');
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
describe('slashes that are not command leaders', () => {
|
|
588
|
+
const nonLeaderInputs = [
|
|
589
|
+
'A: 10/20 and B: 30/40',
|
|
590
|
+
'use and/or here',
|
|
591
|
+
'2026/08/04 is the date',
|
|
592
|
+
'see https://example.com/foo/bar for details',
|
|
593
|
+
'the regex is a\\/b'
|
|
594
|
+
];
|
|
595
|
+
|
|
596
|
+
nonLeaderInputs.forEach(text => {
|
|
597
|
+
it(`keeps ${JSON.stringify(text)} as plain text`, () => expectPlainText(text));
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe('known commands still parse', () => {
|
|
602
|
+
it('parses a known command without arguments', async () => {
|
|
603
|
+
const text = '/summarize';
|
|
604
|
+
const result = await parse(text);
|
|
605
|
+
|
|
606
|
+
expect(result.parts.length).to.equal(1);
|
|
607
|
+
const command = result.parts[0] as ParsedChatRequestVariablePart;
|
|
608
|
+
expect(command.variableName).to.equal('prompt');
|
|
609
|
+
expect(command.variableArg).to.equal('summarize');
|
|
610
|
+
expectFullCoverage(result.parts, text);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('parses a known command with arguments', async () => {
|
|
614
|
+
const text = '/summarize foo bar';
|
|
615
|
+
const result = await parse(text);
|
|
616
|
+
|
|
617
|
+
expect(result.parts.length).to.equal(1);
|
|
618
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo bar');
|
|
619
|
+
expectFullCoverage(result.parts, text);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('keeps a path as the argument of a known command', async () => {
|
|
623
|
+
const text = '/summarize /home/user/notes.txt';
|
|
624
|
+
const result = await parse(text);
|
|
625
|
+
|
|
626
|
+
expect(result.parts.length).to.equal(1);
|
|
627
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|/home/user/notes.txt');
|
|
628
|
+
expectFullCoverage(result.parts, text);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it('keeps an unknown slash token inside the arguments of a known command', async () => {
|
|
632
|
+
const text = '/summarize foo /unknown bar';
|
|
633
|
+
const result = await parse(text);
|
|
634
|
+
|
|
635
|
+
expect(result.parts.length).to.equal(1);
|
|
636
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo /unknown bar');
|
|
637
|
+
expectFullCoverage(result.parts, text);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('parses a known command in the middle of a message', async () => {
|
|
641
|
+
const text = 'please /summarize this';
|
|
642
|
+
const result = await parse(text);
|
|
643
|
+
|
|
644
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['text', 'var']);
|
|
645
|
+
expect(result.parts[0].text).to.equal('please ');
|
|
646
|
+
expect((result.parts[1] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|this');
|
|
647
|
+
expectFullCoverage(result.parts, text);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it('parses two adjacent known commands', async () => {
|
|
651
|
+
const text = '/skill-one /skill-two';
|
|
652
|
+
const result = await parse(text);
|
|
653
|
+
|
|
654
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
655
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one');
|
|
656
|
+
expect(result.parts[1].text).to.equal(' ');
|
|
657
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-two');
|
|
658
|
+
expectFullCoverage(result.parts, text);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('separates two argument-taking known commands', async () => {
|
|
662
|
+
const text = '/summarize foo /explain bar';
|
|
663
|
+
const result = await parse(text);
|
|
664
|
+
|
|
665
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
666
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo');
|
|
667
|
+
expect(result.parts[1].text).to.equal(' ');
|
|
668
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('explain|bar');
|
|
669
|
+
expectFullCoverage(result.parts, text);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('parses the same command twice, each with its own argument', async () => {
|
|
673
|
+
const text = '/hello Klaus /hello Maria';
|
|
674
|
+
const result = await parse(text);
|
|
675
|
+
|
|
676
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
677
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Klaus');
|
|
678
|
+
expect(result.parts[1].text).to.equal(' ');
|
|
679
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Maria');
|
|
680
|
+
expectFullCoverage(result.parts, text);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('parses the same command three times', async () => {
|
|
684
|
+
const text = '/hello Klaus /hello Maria /hello Bob';
|
|
685
|
+
const result = await parse(text);
|
|
686
|
+
|
|
687
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var', 'text', 'var']);
|
|
688
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Klaus');
|
|
689
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Maria');
|
|
690
|
+
expect((result.parts[4] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Bob');
|
|
691
|
+
expectFullCoverage(result.parts, text);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('parses the same command twice without arguments', async () => {
|
|
695
|
+
const text = '/hello /hello';
|
|
696
|
+
const result = await parse(text);
|
|
697
|
+
|
|
698
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
699
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('hello');
|
|
700
|
+
expect(result.parts[1].text).to.equal(' ');
|
|
701
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('hello');
|
|
702
|
+
expectFullCoverage(result.parts, text);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('accepts a prompt fragment id as a command', async () => {
|
|
706
|
+
const text = '/coder-system';
|
|
707
|
+
const result = await parse(text);
|
|
708
|
+
|
|
709
|
+
expect(result.parts.length).to.equal(1);
|
|
710
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('coder-system');
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it('keeps trailing whitespace out of the command part', async () => {
|
|
714
|
+
const text = '/summarize ';
|
|
715
|
+
const result = await parse(text);
|
|
716
|
+
|
|
717
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text']);
|
|
718
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize');
|
|
719
|
+
expect(result.parts[1].text).to.equal(' ');
|
|
720
|
+
expectFullCoverage(result.parts, text);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('trims whitespace around arguments', async () => {
|
|
724
|
+
const text = '/summarize \t foo \t ';
|
|
725
|
+
const result = await parse(text);
|
|
726
|
+
|
|
727
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo');
|
|
728
|
+
expectFullCoverage(result.parts, text);
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
describe('arguments do not span multiple lines', () => {
|
|
733
|
+
it('does not consume the following line for a command without arguments', async () => {
|
|
734
|
+
const text = '/summarize\nsecond line';
|
|
735
|
+
const result = await parse(text);
|
|
736
|
+
|
|
737
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text']);
|
|
738
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize');
|
|
739
|
+
expect(result.parts[1].text).to.equal('\nsecond line');
|
|
740
|
+
expectFullCoverage(result.parts, text);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('does not consume the following line for a command with arguments', async () => {
|
|
744
|
+
const text = '/summarize foo\nsecond line\nthird line';
|
|
745
|
+
const result = await parse(text);
|
|
746
|
+
|
|
747
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text']);
|
|
748
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo');
|
|
749
|
+
expect(result.parts[1].text).to.equal('\nsecond line\nthird line');
|
|
750
|
+
expectFullCoverage(result.parts, text);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('handles CRLF line endings', async () => {
|
|
754
|
+
const text = '/summarize foo\r\nsecond line';
|
|
755
|
+
const result = await parse(text);
|
|
756
|
+
|
|
757
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text']);
|
|
758
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo');
|
|
759
|
+
expect(result.parts[1].text).to.equal('\r\nsecond line');
|
|
760
|
+
expectFullCoverage(result.parts, text);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it('parses a command on a later line', async () => {
|
|
764
|
+
const text = 'first line\n/summarize foo\nthird line';
|
|
765
|
+
const result = await parse(text);
|
|
766
|
+
|
|
767
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['text', 'var', 'text']);
|
|
768
|
+
expect(result.parts[0].text).to.equal('first line\n');
|
|
769
|
+
expect((result.parts[1] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo');
|
|
770
|
+
expect(result.parts[2].text).to.equal('\nthird line');
|
|
771
|
+
expectFullCoverage(result.parts, text);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('parses one command per line', async () => {
|
|
775
|
+
const text = '/summarize foo\n/explain bar';
|
|
776
|
+
const result = await parse(text);
|
|
777
|
+
|
|
778
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
779
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('summarize|foo');
|
|
780
|
+
expect(result.parts[1].text).to.equal('\n');
|
|
781
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('explain|bar');
|
|
782
|
+
expectFullCoverage(result.parts, text);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('parses the same command on consecutive lines', async () => {
|
|
786
|
+
const text = '/hello Klaus\n/hello Maria';
|
|
787
|
+
const result = await parse(text);
|
|
788
|
+
|
|
789
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
790
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Klaus');
|
|
791
|
+
expect(result.parts[1].text).to.equal('\n');
|
|
792
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Maria');
|
|
793
|
+
expectFullCoverage(result.parts, text);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('keeps a blank line between two commands', async () => {
|
|
797
|
+
const text = '/hello Klaus\n\n/hello Maria';
|
|
798
|
+
const result = await parse(text);
|
|
799
|
+
|
|
800
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var']);
|
|
801
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Klaus');
|
|
802
|
+
expect(result.parts[1].text).to.equal('\n\n');
|
|
803
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Maria');
|
|
804
|
+
expectFullCoverage(result.parts, text);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('mixes a line break with a repetition on the same line', async () => {
|
|
808
|
+
const text = '/hello Klaus\n/hello Maria and /hello Bob';
|
|
809
|
+
const result = await parse(text);
|
|
810
|
+
|
|
811
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text', 'var', 'text', 'var']);
|
|
812
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Klaus');
|
|
813
|
+
expect(result.parts[1].text).to.equal('\n');
|
|
814
|
+
expect((result.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Maria and');
|
|
815
|
+
expect(result.parts[3].text).to.equal(' ');
|
|
816
|
+
expect((result.parts[4] as ParsedChatRequestVariablePart).variableArg).to.equal('hello|Bob');
|
|
817
|
+
expectFullCoverage(result.parts, text);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('keeps a multi-line paste containing paths untouched', async () => {
|
|
821
|
+
await expectPlainText('here is a stack trace:\n at /usr/lib/node/foo.js:12\nplease fix it');
|
|
822
|
+
});
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
describe('interaction with other request parts', () => {
|
|
826
|
+
it('does not turn a path into a command when it follows a variable', async () => {
|
|
827
|
+
const text = '#file:/path/to/file.ext look at /home/user/notes.txt';
|
|
828
|
+
const result = await parse(text);
|
|
829
|
+
|
|
830
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['var', 'text']);
|
|
831
|
+
expect((result.parts[0] as ParsedChatRequestVariablePart).variableName).to.equal('file');
|
|
832
|
+
expect(result.parts[1].text).to.equal(' look at /home/user/notes.txt');
|
|
833
|
+
expectFullCoverage(result.parts, text);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it('does not turn a path into a command when it follows an agent mention', async () => {
|
|
837
|
+
chatAgentService.getAgents.returns([{
|
|
838
|
+
id: 'agentA',
|
|
839
|
+
name: 'agentA',
|
|
840
|
+
description: '',
|
|
841
|
+
tags: [],
|
|
842
|
+
variables: [],
|
|
843
|
+
prompts: [],
|
|
844
|
+
agentSpecificVariables: [],
|
|
845
|
+
functions: [],
|
|
846
|
+
languageModelRequirements: [],
|
|
847
|
+
locations: [ChatAgentLocation.Panel],
|
|
848
|
+
invoke: async () => undefined
|
|
849
|
+
} as ChatAgent]);
|
|
850
|
+
const text = '@agentA please read /etc/hosts';
|
|
851
|
+
const result = await parse(text);
|
|
852
|
+
|
|
853
|
+
expect(result.parts.map(p => p.kind)).to.deep.equal(['agent', 'text']);
|
|
854
|
+
expect(result.parts[1].text).to.equal(' please read /etc/hosts');
|
|
855
|
+
expectFullCoverage(result.parts, text);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
528
860
|
describe('parsed chat request part kind assignments', () => {
|
|
529
861
|
it('ParsedChatRequestTextPart has kind assigned at runtime', () => {
|
|
530
862
|
const part = new ParsedChatRequestTextPart({ start: 0, endExclusive: 5 }, 'hello');
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*--------------------------------------------------------------------------------------------*/
|
|
20
20
|
// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatRequestParser.ts
|
|
21
21
|
|
|
22
|
-
import { inject, injectable
|
|
22
|
+
import { inject, injectable } from '@theia/core/shared/inversify';
|
|
23
23
|
import { ChatAgentService } from './chat-agent-service';
|
|
24
24
|
import { ChatAgentLocation } from './chat-agents';
|
|
25
25
|
import { ChatContext, ChatRequest } from './chat-model';
|
|
@@ -47,7 +47,10 @@ const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent
|
|
|
47
47
|
const functionReg = /^~(\??[\w_\-\.]+)(?=(\s|$|\b))/i;
|
|
48
48
|
const functionPromptFormatReg = /^\~\{\s*(.*?)\s*\}/i;
|
|
49
49
|
const variableReg = /^#([\w_\-]+)(?::([\w_\-_\/\\.:]+))?(?=(\s|$|\b))/i; // A #-variable with an optional : arg (#file:workspace/path/name.ext)
|
|
50
|
-
|
|
50
|
+
// A /-command (/commandname) with optional arguments parsed separately. The command name must be
|
|
51
|
+
// terminated by whitespace or the end of the input, so that path segments such as `/home/user` are
|
|
52
|
+
// not mistaken for a command.
|
|
53
|
+
const commandReg = /^\/([\w_\-]+)(?=\s|$)/;
|
|
51
54
|
const nextCommandReg = /\s+\/([\w_\-]+)(?=\s|$)/g;
|
|
52
55
|
|
|
53
56
|
export const ChatRequestParser = Symbol('ChatRequestParser');
|
|
@@ -64,8 +67,8 @@ function offsetRange(start: number, endExclusive: number): OffsetRange {
|
|
|
64
67
|
@injectable()
|
|
65
68
|
export class ChatRequestParserImpl implements ChatRequestParser {
|
|
66
69
|
|
|
67
|
-
@inject(PromptService)
|
|
68
|
-
protected readonly promptService
|
|
70
|
+
@inject(PromptService)
|
|
71
|
+
protected readonly promptService: PromptService;
|
|
69
72
|
|
|
70
73
|
constructor(
|
|
71
74
|
@inject(ChatAgentService) private readonly agentService: ChatAgentService,
|
|
@@ -299,11 +302,20 @@ export class ChatRequestParserImpl implements ChatRequestParser {
|
|
|
299
302
|
}
|
|
300
303
|
|
|
301
304
|
const [commandText, commandName] = nextCommandMatch;
|
|
305
|
+
if (!this.isCommandCandidate(commandName)) {
|
|
306
|
+
// Not a command we know about, so leave the text alone. Otherwise anything the user
|
|
307
|
+
// types after a `/word` token, e.g. a Unix path, would be swallowed as command
|
|
308
|
+
// arguments and silently dropped when the non-existing command fails to resolve.
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
302
311
|
let commandEnd = commandText.length;
|
|
303
312
|
let commandArgs: string | undefined;
|
|
304
313
|
|
|
305
|
-
|
|
306
|
-
const
|
|
314
|
+
// Arguments never span multiple lines: a command only consumes the remainder of its own line.
|
|
315
|
+
const lineBreakOffset = message.indexOf('\n', commandEnd);
|
|
316
|
+
const lineEnd = lineBreakOffset === -1 ? message.length : lineBreakOffset;
|
|
317
|
+
const nextCommandOffset = this.findNextCommandOffset(message, commandEnd, lineEnd);
|
|
318
|
+
const argsEnd = nextCommandOffset ?? lineEnd;
|
|
307
319
|
const rawArgs = message.slice(commandEnd, argsEnd);
|
|
308
320
|
const args = rawArgs.trim();
|
|
309
321
|
if (args) {
|
|
@@ -321,16 +333,11 @@ export class ChatRequestParserImpl implements ChatRequestParser {
|
|
|
321
333
|
return new ParsedChatRequestVariablePart(commandRange, 'prompt', variableArg);
|
|
322
334
|
}
|
|
323
335
|
|
|
324
|
-
private findNextCommandOffset(message: string, startOffset: number): number | undefined {
|
|
336
|
+
private findNextCommandOffset(message: string, startOffset: number, endOffset: number): number | undefined {
|
|
325
337
|
nextCommandReg.lastIndex = startOffset;
|
|
326
338
|
let match = nextCommandReg.exec(message);
|
|
327
|
-
while (match) {
|
|
328
|
-
|
|
329
|
-
// Deliberate behavior difference: without a PromptService we cannot tell commands from
|
|
330
|
-
// path-like arguments, so any `/word` token is treated as a command boundary. With a
|
|
331
|
-
// PromptService we only break on known command names and keep unknown `/word` tokens
|
|
332
|
-
// (e.g. `/tmp`, `/path/to/file`) as part of the current command's arguments.
|
|
333
|
-
if (!this.promptService || this.isKnownCommand(commandName)) {
|
|
339
|
+
while (match && match.index < endOffset) {
|
|
340
|
+
if (this.isCommandCandidate(match[1])) {
|
|
334
341
|
return match.index + match[0].indexOf(chatSubcommandLeader);
|
|
335
342
|
}
|
|
336
343
|
match = nextCommandReg.exec(message);
|
|
@@ -338,10 +345,13 @@ export class ChatRequestParserImpl implements ChatRequestParser {
|
|
|
338
345
|
return undefined;
|
|
339
346
|
}
|
|
340
347
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Whether a `/name` token should be treated as a command. Only names that actually resolve to a
|
|
350
|
+
* command or prompt fragment are accepted, which keeps unknown `/word` tokens (e.g. `/tmp`,
|
|
351
|
+
* `/path/to/file`) as plain text.
|
|
352
|
+
*/
|
|
353
|
+
protected isCommandCandidate(commandName: string): boolean {
|
|
354
|
+
return this.promptService.isKnownCommand(commandName);
|
|
345
355
|
}
|
|
346
356
|
|
|
347
357
|
private tryToParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined {
|