@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.
@@ -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
+ });
@@ -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
- nextContent.onDidChange(() => this._onDidChangeEmitter.fire());
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('uses known command names to disambiguate slash command arguments', async () => {
328
- const promptService = {
329
- getCommands: () => [
330
- { id: 'command-skill-one', template: '', isCommand: true, commandName: 'skill-one' },
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
- const multipleCommands = await parserWithPromptService.parseChatRequest({ text: '/skill-one /skill-two' }, ChatAgentLocation.Panel, context);
339
- expect((multipleCommands.parts[0] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-one');
340
- expect((multipleCommands.parts[2] as ParsedChatRequestVariablePart).variableArg).to.equal('skill-two');
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, optional } from '@theia/core/shared/inversify';
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
- const commandReg = /^\/([\w_\-]+)/; // A /-command (/commandname) with optional arguments parsed separately
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) @optional()
68
- protected readonly promptService?: 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
- const nextCommandOffset = this.findNextCommandOffset(message, commandEnd);
306
- const argsEnd = nextCommandOffset ?? message.length;
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
- const commandName = match[1];
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
- private isKnownCommand(commandName: string): boolean {
342
- return this.promptService?.getCommands().some(command =>
343
- (command.commandName ?? command.id) === commandName
344
- ) ?? false;
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 {