@theia/ai-chat 1.74.0-next.9 → 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.
Files changed (102) hide show
  1. package/lib/browser/agent-delegation-tool.js +6 -7
  2. package/lib/browser/agent-delegation-tool.js.map +1 -1
  3. package/lib/browser/agent-delegation-tool.spec.js +13 -0
  4. package/lib/browser/agent-delegation-tool.spec.js.map +1 -1
  5. package/lib/browser/chat-request-command-resolution.spec.d.ts +2 -0
  6. package/lib/browser/chat-request-command-resolution.spec.d.ts.map +1 -0
  7. package/lib/browser/chat-request-command-resolution.spec.js +94 -0
  8. package/lib/browser/chat-request-command-resolution.spec.js.map +1 -0
  9. package/lib/browser/chat-session-store-impl.d.ts.map +1 -1
  10. package/lib/browser/chat-session-store-impl.js +2 -0
  11. package/lib/browser/chat-session-store-impl.js.map +1 -1
  12. package/lib/browser/chat-session-store-impl.spec.js +51 -2
  13. package/lib/browser/chat-session-store-impl.spec.js.map +1 -1
  14. package/lib/browser/chat-tool-preference-bindings.d.ts +18 -8
  15. package/lib/browser/chat-tool-preference-bindings.d.ts.map +1 -1
  16. package/lib/browser/chat-tool-preference-bindings.js +27 -28
  17. package/lib/browser/chat-tool-preference-bindings.js.map +1 -1
  18. package/lib/browser/chat-tool-preference-bindings.spec.js +43 -46
  19. package/lib/browser/chat-tool-preference-bindings.spec.js.map +1 -1
  20. package/lib/common/chat-agents.d.ts +8 -0
  21. package/lib/common/chat-agents.d.ts.map +1 -1
  22. package/lib/common/chat-agents.js +35 -1
  23. package/lib/common/chat-agents.js.map +1 -1
  24. package/lib/common/chat-agents.spec.js +94 -0
  25. package/lib/common/chat-agents.spec.js.map +1 -1
  26. package/lib/common/chat-auto-save.spec.js +13 -0
  27. package/lib/common/chat-auto-save.spec.js.map +1 -1
  28. package/lib/common/chat-content-deserializer.d.ts.map +1 -1
  29. package/lib/common/chat-content-deserializer.js +4 -0
  30. package/lib/common/chat-content-deserializer.js.map +1 -1
  31. package/lib/common/chat-model-serialization.d.ts +12 -1
  32. package/lib/common/chat-model-serialization.d.ts.map +1 -1
  33. package/lib/common/chat-model-serialization.js.map +1 -1
  34. package/lib/common/chat-model-serialization.spec.js +29 -0
  35. package/lib/common/chat-model-serialization.spec.js.map +1 -1
  36. package/lib/common/chat-model.d.ts +105 -2
  37. package/lib/common/chat-model.d.ts.map +1 -1
  38. package/lib/common/chat-model.js +159 -3
  39. package/lib/common/chat-model.js.map +1 -1
  40. package/lib/common/chat-model.spec.d.ts +2 -0
  41. package/lib/common/chat-model.spec.d.ts.map +1 -0
  42. package/lib/common/chat-model.spec.js +42 -0
  43. package/lib/common/chat-model.spec.js.map +1 -0
  44. package/lib/common/chat-request-parser.d.ts +9 -1
  45. package/lib/common/chat-request-parser.d.ts.map +1 -1
  46. package/lib/common/chat-request-parser.js +54 -3
  47. package/lib/common/chat-request-parser.js.map +1 -1
  48. package/lib/common/chat-request-parser.spec.js +333 -0
  49. package/lib/common/chat-request-parser.spec.js.map +1 -1
  50. package/lib/common/chat-response-model.spec.js +31 -0
  51. package/lib/common/chat-response-model.spec.js.map +1 -1
  52. package/lib/common/chat-service-deletion.spec.js +43 -0
  53. package/lib/common/chat-service-deletion.spec.js.map +1 -1
  54. package/lib/common/chat-service-pinned-agent.spec.js +52 -2
  55. package/lib/common/chat-service-pinned-agent.spec.js.map +1 -1
  56. package/lib/common/chat-service-session-events.spec.d.ts +2 -0
  57. package/lib/common/chat-service-session-events.spec.d.ts.map +1 -0
  58. package/lib/common/chat-service-session-events.spec.js +130 -0
  59. package/lib/common/chat-service-session-events.spec.js.map +1 -0
  60. package/lib/common/chat-service.d.ts +3 -0
  61. package/lib/common/chat-service.d.ts.map +1 -1
  62. package/lib/common/chat-service.js +60 -6
  63. package/lib/common/chat-service.js.map +1 -1
  64. package/lib/common/chat-service.spec.d.ts +2 -0
  65. package/lib/common/chat-service.spec.d.ts.map +1 -0
  66. package/lib/common/chat-service.spec.js +177 -0
  67. package/lib/common/chat-service.spec.js.map +1 -0
  68. package/lib/common/chat-session-status.spec.d.ts +2 -0
  69. package/lib/common/chat-session-status.spec.d.ts.map +1 -0
  70. package/lib/common/chat-session-status.spec.js +213 -0
  71. package/lib/common/chat-session-status.spec.js.map +1 -0
  72. package/lib/common/chat-session-store.d.ts +8 -0
  73. package/lib/common/chat-session-store.d.ts.map +1 -1
  74. package/lib/common/tool-call-response-content.spec.js +36 -0
  75. package/lib/common/tool-call-response-content.spec.js.map +1 -1
  76. package/package.json +11 -11
  77. package/src/browser/agent-delegation-tool.spec.ts +16 -0
  78. package/src/browser/agent-delegation-tool.ts +7 -7
  79. package/src/browser/chat-request-command-resolution.spec.ts +117 -0
  80. package/src/browser/chat-session-store-impl.spec.ts +62 -2
  81. package/src/browser/chat-session-store-impl.ts +2 -0
  82. package/src/browser/chat-tool-preference-bindings.spec.ts +43 -48
  83. package/src/browser/chat-tool-preference-bindings.ts +42 -30
  84. package/src/common/chat-agents.spec.ts +112 -2
  85. package/src/common/chat-agents.ts +44 -1
  86. package/src/common/chat-auto-save.spec.ts +18 -1
  87. package/src/common/chat-content-deserializer.ts +11 -0
  88. package/src/common/chat-model-serialization.spec.ts +38 -0
  89. package/src/common/chat-model-serialization.ts +12 -1
  90. package/src/common/chat-model.spec.ts +42 -0
  91. package/src/common/chat-model.ts +241 -3
  92. package/src/common/chat-request-parser.spec.ts +401 -1
  93. package/src/common/chat-request-parser.ts +60 -5
  94. package/src/common/chat-response-model.spec.ts +41 -1
  95. package/src/common/chat-service-deletion.spec.ts +52 -0
  96. package/src/common/chat-service-pinned-agent.spec.ts +58 -2
  97. package/src/common/chat-service-session-events.spec.ts +161 -0
  98. package/src/common/chat-service.spec.ts +211 -0
  99. package/src/common/chat-service.ts +64 -8
  100. package/src/common/chat-session-status.spec.ts +280 -0
  101. package/src/common/chat-session-store.ts +8 -0
  102. package/src/common/tool-call-response-content.spec.ts +42 -0
@@ -20,17 +20,29 @@ import { ChatRequestParserImpl } from './chat-request-parser';
20
20
  import { ChatAgent, ChatAgentLocation } from './chat-agents';
21
21
  import { ChatContext, ChatRequest } from './chat-model';
22
22
  import { expect } from 'chai';
23
- import { AIVariable, DefaultAIVariableService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core';
23
+ import { AIVariable, DefaultAIVariableService, PromptService, ResolvedAIVariable, ToolInvocationRegistryImpl, ToolRequest } from '@theia/ai-core';
24
24
  import { ILogger, Logger } from '@theia/core';
25
25
  import { ParsedChatRequestAgentPart, ParsedChatRequestFunctionPart, ParsedChatRequestTextPart, ParsedChatRequestVariablePart } from './parsed-chat-request';
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
@@ -276,6 +288,66 @@ describe('ChatRequestParserImpl', () => {
276
288
  expect(varPart.variableArg).to.equal('cmd|"arg with \\"quote\\"" other');
277
289
  });
278
290
 
291
+ it('parses multiple commands in one message', async () => {
292
+ const req: ChatRequest = {
293
+ text: '/skill-one /skill-two'
294
+ };
295
+ const context: ChatContext = { variables: [] };
296
+ const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
297
+
298
+ expect(result.parts.length).to.equal(3);
299
+ const firstCommand = result.parts[0] as ParsedChatRequestVariablePart;
300
+ const separator = result.parts[1] as ParsedChatRequestTextPart;
301
+ const secondCommand = result.parts[2] as ParsedChatRequestVariablePart;
302
+ expect(firstCommand.variableName).to.equal('prompt');
303
+ expect(firstCommand.variableArg).to.equal('skill-one');
304
+ expect(separator.text).to.equal(' ');
305
+ expect(secondCommand.variableName).to.equal('prompt');
306
+ expect(secondCommand.variableArg).to.equal('skill-two');
307
+ });
308
+
309
+ it('inserts a separator between two argument-taking commands', async () => {
310
+ const req: ChatRequest = {
311
+ text: '/summarize foo /hello bar'
312
+ };
313
+ const context: ChatContext = { variables: [] };
314
+ const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
315
+
316
+ expect(result.parts.length).to.equal(3);
317
+ const firstCommand = result.parts[0] as ParsedChatRequestVariablePart;
318
+ const separator = result.parts[1] as ParsedChatRequestTextPart;
319
+ const secondCommand = result.parts[2] as ParsedChatRequestVariablePart;
320
+ expect(firstCommand.variableArg).to.equal('summarize|foo');
321
+ expect(separator.kind).to.equal('text');
322
+ expect(separator.text).to.equal(' ');
323
+ expect(secondCommand.variableArg).to.equal('hello|bar');
324
+ });
325
+
326
+ it('keeps path-like slash arguments as command arguments', async () => {
327
+ const req: ChatRequest = {
328
+ text: '/explain /path/to/file'
329
+ };
330
+ const context: ChatContext = { variables: [] };
331
+ const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
332
+
333
+ expect(result.parts.length).to.equal(1);
334
+ const varPart = result.parts[0] as ParsedChatRequestVariablePart;
335
+ expect(varPart.variableName).to.equal('prompt');
336
+ expect(varPart.variableArg).to.equal('explain|/path/to/file');
337
+ });
338
+
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
+ };
343
+ const context: ChatContext = { variables: [] };
344
+ const result = await parser.parseChatRequest(req, ChatAgentLocation.Panel, context);
345
+
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);
349
+ });
350
+
279
351
  it('treats the first @agent mention as the selector and does not allow later mentions to override it', async () => {
280
352
  const createAgent = (id: string): ChatAgent => ({
281
353
  id,
@@ -457,6 +529,334 @@ describe('ChatRequestParserImpl', () => {
457
529
  });
458
530
  });
459
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
+
460
860
  describe('parsed chat request part kind assignments', () => {
461
861
  it('ParsedChatRequestTextPart has kind assigned at runtime', () => {
462
862
  const part = new ParsedChatRequestTextPart({ start: 0, endExclusive: 5 }, 'hello');
@@ -37,7 +37,7 @@ import {
37
37
  ParsedChatRequestPart,
38
38
  } from './parsed-chat-request';
39
39
  import {
40
- AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, parseFunctionReference, ToolInvocationRegistry, ToolRequest
40
+ AIVariable, AIVariableService, createAIResolveVariableCache, getAllResolvedAIVariables, parseFunctionReference, PromptService, ToolInvocationRegistry, ToolRequest
41
41
  } from '@theia/ai-core';
42
42
  import { ILogger } from '@theia/core';
43
43
 
@@ -47,7 +47,11 @@ 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_\-]+)(?:\s+(.+?))?(?=\s*$)/; // A /-command with optional arguments (/commandname arg1 arg2)
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|$)/;
54
+ const nextCommandReg = /\s+\/([\w_\-]+)(?=\s|$)/g;
51
55
 
52
56
  export const ChatRequestParser = Symbol('ChatRequestParser');
53
57
  export interface ChatRequestParser {
@@ -62,11 +66,15 @@ function offsetRange(start: number, endExclusive: number): OffsetRange {
62
66
  }
63
67
  @injectable()
64
68
  export class ChatRequestParserImpl implements ChatRequestParser {
69
+
70
+ @inject(PromptService)
71
+ protected readonly promptService: PromptService;
72
+
65
73
  constructor(
66
74
  @inject(ChatAgentService) private readonly agentService: ChatAgentService,
67
75
  @inject(AIVariableService) private readonly variableService: AIVariableService,
68
76
  @inject(ToolInvocationRegistry) private readonly toolInvocationRegistry: ToolInvocationRegistry,
69
- @inject(ILogger) private readonly logger: ILogger
77
+ @inject(ILogger) private readonly logger: ILogger,
70
78
  ) { }
71
79
 
72
80
  async parseChatRequest(request: ChatRequest, location: ChatAgentLocation, context: ChatContext): Promise<ParsedChatRequest> {
@@ -185,6 +193,7 @@ export class ChatRequestParserImpl implements ChatRequestParser {
185
193
  }
186
194
 
187
195
  parts.push(newPart);
196
+ i = newPart.range.endExclusive - 1;
188
197
  }
189
198
  }
190
199
 
@@ -292,13 +301,59 @@ export class ChatRequestParserImpl implements ChatRequestParser {
292
301
  return;
293
302
  }
294
303
 
295
- const [full, commandName, commandArgs] = nextCommandMatch;
296
- const commandRange = offsetRange(offset, offset + full.length);
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
+ }
311
+ let commandEnd = commandText.length;
312
+ let commandArgs: string | undefined;
313
+
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;
319
+ const rawArgs = message.slice(commandEnd, argsEnd);
320
+ const args = rawArgs.trim();
321
+ if (args) {
322
+ commandArgs = args;
323
+ // Advance the consumed range only up to the end of the trimmed argument, not up to the
324
+ // next command. This keeps the whitespace between the argument and a following command
325
+ // out of this part's range, so parseParts emits it as a separator text part. Otherwise
326
+ // the two resolved prompt fragments would run into each other with no space between them.
327
+ commandEnd = argsEnd - (rawArgs.length - rawArgs.trimEnd().length);
328
+ }
329
+
330
+ const commandRange = offsetRange(offset, offset + commandEnd);
297
331
 
298
332
  const variableArg = commandArgs ? `${commandName}|${commandArgs}` : commandName;
299
333
  return new ParsedChatRequestVariablePart(commandRange, 'prompt', variableArg);
300
334
  }
301
335
 
336
+ private findNextCommandOffset(message: string, startOffset: number, endOffset: number): number | undefined {
337
+ nextCommandReg.lastIndex = startOffset;
338
+ let match = nextCommandReg.exec(message);
339
+ while (match && match.index < endOffset) {
340
+ if (this.isCommandCandidate(match[1])) {
341
+ return match.index + match[0].indexOf(chatSubcommandLeader);
342
+ }
343
+ match = nextCommandReg.exec(message);
344
+ }
345
+ return undefined;
346
+ }
347
+
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);
355
+ }
356
+
302
357
  private tryToParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined {
303
358
  // Support both the chat and prompt formats for functions
304
359
  const nextFunctionMatch = message.match(functionPromptFormatReg) || message.match(functionReg);
@@ -15,7 +15,7 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
- import { MutableChatResponseModel, ToolCallChatResponseContentImpl } from './chat-model';
18
+ import { MarkdownChatResponseContentImpl, MutableChatResponseModel, ToolCallChatResponseContentImpl } from './chat-model';
19
19
 
20
20
  describe('MutableChatResponseModel', () => {
21
21
  describe('content change propagation', () => {
@@ -35,6 +35,46 @@ describe('MutableChatResponseModel', () => {
35
35
  // lost on reload.
36
36
  expect(fireCount).to.equal(1);
37
37
  });
38
+
39
+ it('should not accumulate duplicate listeners when content is cleared and re-added', () => {
40
+ const response = new MutableChatResponseModel('req-1');
41
+ const toolCall = new ToolCallChatResponseContentImpl('tool-1', 'tool', '{}', false);
42
+ response.response.addContent(toolCall);
43
+
44
+ // Simulate AbstractStreamParsingChatAgent.addStreamResponse: for every streamed
45
+ // text token, the whole content is cleared and re-added, tool call included.
46
+ for (let i = 0; i < 10; i++) {
47
+ const contentBeforeMarker = response.response.content.slice(0, 1);
48
+ response.response.clearContent();
49
+ response.response.addContents(contentBeforeMarker);
50
+ response.response.addContents([new MarkdownChatResponseContentImpl(`token ${i}`)]);
51
+ }
52
+
53
+ let fireCount = 0;
54
+ response.onDidChange(() => { fireCount++; });
55
+
56
+ toolCall.updateResult('result');
57
+
58
+ // A single tool call change must fire exactly once, no matter how often the
59
+ // content array was rebuilt during streaming (see #17858).
60
+ expect(fireCount).to.equal(1);
61
+ });
62
+
63
+ it('should stop propagating changes of content that was cleared and not re-added', () => {
64
+ const response = new MutableChatResponseModel('req-1');
65
+ const toolCall = new ToolCallChatResponseContentImpl('tool-1', 'tool', '{}', false);
66
+ response.response.addContent(toolCall);
67
+ response.response.clearContent();
68
+
69
+ let fireCount = 0;
70
+ response.onDidChange(() => { fireCount++; });
71
+
72
+ toolCall.updateResult('result');
73
+
74
+ // Content that is no longer part of the response must not trigger response changes
75
+ // (and with that auto-save) anymore.
76
+ expect(fireCount).to.equal(0);
77
+ });
38
78
  });
39
79
 
40
80
  describe('setTokenUsage', () => {
@@ -267,6 +267,58 @@ describe('ChatService Session Deletion', () => {
267
267
  expect(sessionStore.deletedSessions).to.include(persistedSessionId);
268
268
  });
269
269
 
270
+ it('should cascade-delete descendants when deleting an intermediate session (A -> B -> C, in memory)', async () => {
271
+ // Delegation chain A -> B -> C, all loaded in memory. C's rootSessionId points at A (the
272
+ // root of the chain), but its immediate parent is B, so deleting B must still remove C.
273
+ const a = chatService.createSession(ChatAgentLocation.Panel);
274
+ const b = chatService.createSession(ChatAgentLocation.Panel);
275
+ const c = chatService.createSession(ChatAgentLocation.Panel);
276
+ const internal = b as unknown as { rootSessionId?: string; parentSessionId?: string };
277
+ internal.rootSessionId = a.id;
278
+ internal.parentSessionId = a.id;
279
+ const cInternal = c as unknown as { rootSessionId?: string; parentSessionId?: string };
280
+ cInternal.rootSessionId = a.id;
281
+ cInternal.parentSessionId = b.id;
282
+
283
+ await chatService.deleteSession(b.id);
284
+
285
+ const remaining = chatService.getSessions().map(s => s.id);
286
+ expect(remaining).to.include(a.id);
287
+ expect(remaining).to.not.include(b.id);
288
+ expect(remaining).to.not.include(c.id);
289
+ expect(sessionStore.deletedSessions).to.include(b.id);
290
+ expect(sessionStore.deletedSessions).to.include(c.id);
291
+ });
292
+
293
+ it('should cascade-delete persisted-only descendants of an intermediate session', async () => {
294
+ // The whole chain lives only in the persisted index (e.g. after a reload). Deleting the
295
+ // intermediate B must reach C via its parentSessionId even though C's rootSessionId is A.
296
+ sessionStore.getSessionIndex = async (): Promise<ChatSessionIndex> => ({
297
+ A: { sessionId: 'A', title: 'A', saveDate: 0, location: ChatAgentLocation.Panel },
298
+ B: { sessionId: 'B', title: 'B', saveDate: 0, location: ChatAgentLocation.Panel, rootSessionId: 'A', parentSessionId: 'A' },
299
+ C: { sessionId: 'C', title: 'C', saveDate: 0, location: ChatAgentLocation.Panel, rootSessionId: 'A', parentSessionId: 'B' }
300
+ });
301
+
302
+ await chatService.deleteSession('B');
303
+
304
+ expect(sessionStore.deletedSessions).to.include('B');
305
+ expect(sessionStore.deletedSessions).to.include('C');
306
+ expect(sessionStore.deletedSessions).to.not.include('A');
307
+ });
308
+
309
+ it('should not loop forever on a cyclic parent reference', async () => {
310
+ // Defensive: a corrupted index where two sessions reference each other must still terminate.
311
+ sessionStore.getSessionIndex = async (): Promise<ChatSessionIndex> => ({
312
+ X: { sessionId: 'X', title: 'X', saveDate: 0, location: ChatAgentLocation.Panel, parentSessionId: 'Y' },
313
+ Y: { sessionId: 'Y', title: 'Y', saveDate: 0, location: ChatAgentLocation.Panel, parentSessionId: 'X' }
314
+ });
315
+
316
+ await chatService.deleteSession('X');
317
+
318
+ expect(sessionStore.deletedSessions).to.include('X');
319
+ expect(sessionStore.deletedSessions).to.include('Y');
320
+ });
321
+
270
322
  it('should fire SessionDeletedEvent for persisted-only sessions', async () => {
271
323
  // When deleting a persisted-only session (not in memory),
272
324
  // we still fire the event so consumers (e.g. the Welcome view's chat cards)