@theia/ai-chat 1.74.0 → 1.75.0-next.3
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-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/package.json +9 -9
- package/src/browser/chat-request-command-resolution.spec.ts +117 -0
- package/src/common/chat-request-parser.spec.ts +348 -16
- package/src/common/chat-request-parser.ts +28 -18
|
@@ -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 {
|