@theia/ai-chat 1.72.0-next.59 → 1.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/lib/browser/chat-session-store-impl.d.ts.map +1 -1
  2. package/lib/browser/chat-session-store-impl.js +7 -2
  3. package/lib/browser/chat-session-store-impl.js.map +1 -1
  4. package/lib/common/chat-content-deserializer.d.ts.map +1 -1
  5. package/lib/common/chat-content-deserializer.js +5 -6
  6. package/lib/common/chat-content-deserializer.js.map +1 -1
  7. package/lib/common/chat-content-deserializer.spec.js +12 -1
  8. package/lib/common/chat-content-deserializer.spec.js.map +1 -1
  9. package/lib/common/chat-model.d.ts +23 -1
  10. package/lib/common/chat-model.d.ts.map +1 -1
  11. package/lib/common/chat-model.js +23 -9
  12. package/lib/common/chat-model.js.map +1 -1
  13. package/lib/common/chat-response-model.spec.js +15 -0
  14. package/lib/common/chat-response-model.spec.js.map +1 -1
  15. package/lib/common/chat-service.d.ts.map +1 -1
  16. package/lib/common/chat-service.js +4 -2
  17. package/lib/common/chat-service.js.map +1 -1
  18. package/lib/common/chat-session-store.d.ts +8 -0
  19. package/lib/common/chat-session-store.d.ts.map +1 -1
  20. package/lib/common/index.d.ts +1 -0
  21. package/lib/common/index.d.ts.map +1 -1
  22. package/lib/common/index.js +1 -0
  23. package/lib/common/index.js.map +1 -1
  24. package/lib/common/provider-error-formatter.d.ts +16 -0
  25. package/lib/common/provider-error-formatter.d.ts.map +1 -0
  26. package/lib/common/provider-error-formatter.js +111 -0
  27. package/lib/common/provider-error-formatter.js.map +1 -0
  28. package/lib/common/provider-error-formatter.spec.d.ts +2 -0
  29. package/lib/common/provider-error-formatter.spec.d.ts.map +1 -0
  30. package/lib/common/provider-error-formatter.spec.js +167 -0
  31. package/lib/common/provider-error-formatter.spec.js.map +1 -0
  32. package/lib/common/tool-call-response-content.spec.js +17 -0
  33. package/lib/common/tool-call-response-content.spec.js.map +1 -1
  34. package/package.json +10 -10
  35. package/src/browser/chat-session-store-impl.ts +7 -2
  36. package/src/common/chat-content-deserializer.spec.ts +16 -1
  37. package/src/common/chat-content-deserializer.ts +7 -6
  38. package/src/common/chat-model.ts +53 -11
  39. package/src/common/chat-response-model.spec.ts +20 -1
  40. package/src/common/chat-service.ts +4 -2
  41. package/src/common/chat-session-store.ts +8 -0
  42. package/src/common/index.ts +1 -0
  43. package/src/common/provider-error-formatter.spec.ts +190 -0
  44. package/src/common/provider-error-formatter.ts +110 -0
  45. package/src/common/tool-call-response-content.spec.ts +23 -0
@@ -15,9 +15,28 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
- import { MutableChatResponseModel } from './chat-model';
18
+ import { MutableChatResponseModel, ToolCallChatResponseContentImpl } from './chat-model';
19
19
 
20
20
  describe('MutableChatResponseModel', () => {
21
+ describe('content change propagation', () => {
22
+ it('should fire onDidChange when a tool call\'s result is updated after it was added', () => {
23
+ const response = new MutableChatResponseModel('req-1');
24
+ const toolCall = new ToolCallChatResponseContentImpl('tool-1', 'tool', '{}', false);
25
+ response.response.addContent(toolCall);
26
+
27
+ let fireCount = 0;
28
+ response.onDidChange(() => { fireCount++; });
29
+
30
+ toolCall.updateResult('partial');
31
+
32
+ // The response model must observe the change so auto-save can persist
33
+ // intermediate state. Without this propagation, mutations that don't go
34
+ // through addContent/merge (e.g. renderer-side partial results) would be
35
+ // lost on reload.
36
+ expect(fireCount).to.equal(1);
37
+ });
38
+ });
39
+
21
40
  describe('setTokenUsage', () => {
22
41
  it('should also add a token usage entry', () => {
23
42
  const response = new MutableChatResponseModel('req-1');
@@ -455,9 +455,11 @@ export class ChatServiceImpl implements ChatService {
455
455
  return Promise.resolve();
456
456
  }
457
457
 
458
- // Store session with title and pinned agent info
458
+ // Store session with title, pinned agent info, last interaction timestamp, and error state
459
+ const lastRequest = session.model.getRequests().at(-1);
460
+ const hasError = lastRequest?.response.isComplete === true && lastRequest?.response.isError === true;
459
461
  return this.sessionStore.storeSessions(
460
- { model: session.model, title: session.title, pinnedAgentId: session.pinnedAgent?.id }
462
+ { model: session.model, title: session.title, pinnedAgentId: session.pinnedAgent?.id, lastInteraction: session.lastInteraction?.getTime(), hasError }
461
463
  ).catch(error => {
462
464
  this.logger.error('Failed to store chat sessions', error);
463
465
  });
@@ -24,6 +24,10 @@ export interface ChatModelWithMetadata {
24
24
  model: ChatModel;
25
25
  title?: string;
26
26
  pinnedAgentId?: string;
27
+ /** Timestamp (ms since epoch) of the last user interaction. Used as the display date for session cards. */
28
+ lastInteraction?: number;
29
+ /** Whether the last request ended with an error. */
30
+ hasError?: boolean;
27
31
  }
28
32
 
29
33
  export interface ChatSessionStore {
@@ -67,4 +71,8 @@ export interface ChatSessionMetadata {
67
71
  title: string;
68
72
  saveDate: number;
69
73
  location: ChatAgentLocation;
74
+ /** ID of the agent pinned to the session (used for icon and display name on cards). */
75
+ pinnedAgentId?: string;
76
+ /** Whether the last request ended with an error. */
77
+ hasError?: boolean;
70
78
  }
@@ -28,3 +28,4 @@ export * from './parsed-chat-request';
28
28
  export * from './context-variables';
29
29
  export * from './chat-tool-request-service';
30
30
  export * from './chat-tool-confirmation-timeout';
31
+ export * from './provider-error-formatter';
@@ -0,0 +1,190 @@
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 { expect } from 'chai';
18
+ import { formatProviderError, formattedProviderErrorToShortString } from './provider-error-formatter';
19
+
20
+ describe('formatProviderError', () => {
21
+
22
+ it('should handle undefined input', () => {
23
+ const result = formatProviderError(undefined);
24
+ expect(result.message).to.equal('');
25
+ expect(result.status).to.be.undefined;
26
+ expect(result.details).to.be.undefined;
27
+ expect(result.raw).to.equal('');
28
+ });
29
+
30
+ it('should handle empty string input', () => {
31
+ const result = formatProviderError('');
32
+ expect(result.message).to.equal('');
33
+ expect(result.status).to.be.undefined;
34
+ expect(result.details).to.be.undefined;
35
+ expect(result.raw).to.equal('');
36
+ });
37
+
38
+ it('should handle plain text error without JSON', () => {
39
+ const result = formatProviderError('Something went wrong');
40
+ expect(result.message).to.equal('Something went wrong');
41
+ expect(result.status).to.be.undefined;
42
+ expect(result.details).to.be.undefined;
43
+ expect(result.raw).to.equal('Something went wrong');
44
+ });
45
+
46
+ it('should extract HTTP status from leading prefix', () => {
47
+ const raw = '401 {"error":{"message":"Invalid API key","type":"auth_error"}}';
48
+ const result = formatProviderError(raw);
49
+ expect(result.status).to.equal('401');
50
+ expect(result.message).to.equal('Invalid API key');
51
+ expect(result.details).to.be.a('string');
52
+ expect(result.raw).to.equal(raw);
53
+ });
54
+
55
+ it('should extract status from JSON code field when no leading prefix', () => {
56
+ const raw = '{"error":{"message":"Permission denied","code":403}}';
57
+ const result = formatProviderError(raw);
58
+ expect(result.status).to.equal('403');
59
+ expect(result.message).to.equal('Permission denied');
60
+ });
61
+
62
+ it('should extract status from JSON status field', () => {
63
+ const raw = '{"error":{"message":"Not found","status":404}}';
64
+ const result = formatProviderError(raw);
65
+ expect(result.status).to.equal('404');
66
+ expect(result.message).to.equal('Not found');
67
+ });
68
+
69
+ it('should prefer leading HTTP status over JSON code field', () => {
70
+ const raw = '500 {"error":{"message":"Server error","code":503}}';
71
+ const result = formatProviderError(raw);
72
+ expect(result.status).to.equal('500');
73
+ expect(result.message).to.equal('Server error');
74
+ });
75
+
76
+ it('should extract the deepest message from nested JSON', () => {
77
+ const raw = '400 {"error":{"message":"Top level","details":{"message":"Deeper reason"}}}';
78
+ const result = formatProviderError(raw);
79
+ expect(result.status).to.equal('400');
80
+ expect(result.message).to.equal('Deeper reason');
81
+ });
82
+
83
+ it('should handle Gemini-style errors with JSON-encoded inner body', () => {
84
+ const inner = JSON.stringify({ error: { message: 'API key not valid', status: 'INVALID_ARGUMENT' } });
85
+ const raw = `{"error":{"message":${JSON.stringify(inner)},"code":400}}`;
86
+ const result = formatProviderError(raw);
87
+ expect(result.status).to.equal('400');
88
+ expect(result.message).to.equal('API key not valid');
89
+ });
90
+
91
+ it('should fall back to remainder when JSON has no message field', () => {
92
+ const raw = '429 {"error":{"type":"rate_limit"}}';
93
+ const result = formatProviderError(raw);
94
+ expect(result.status).to.equal('429');
95
+ expect(result.message).to.equal('{"error":{"type":"rate_limit"}}');
96
+ });
97
+
98
+ it('should not treat a leading 3-digit prefix as a status when no JSON body follows', () => {
99
+ // Avoid misreading arbitrary text that happens to start with 3 digits.
100
+ const raw = '404 routes were processed';
101
+ const result = formatProviderError(raw);
102
+ expect(result.status).to.be.undefined;
103
+ expect(result.message).to.equal('404 routes were processed');
104
+ expect(result.details).to.be.undefined;
105
+ });
106
+
107
+ it('should produce unwrapped details for nested JSON strings', () => {
108
+ const inner = JSON.stringify({ reason: 'quota exceeded' });
109
+ const raw = `{"error":{"message":"Rate limited","body":${JSON.stringify(inner)}}}`;
110
+ const result = formatProviderError(raw);
111
+ expect(result.details).to.be.a('string');
112
+ // The details should contain the unwrapped inner object, not the escaped JSON string
113
+ const parsed = JSON.parse(result.details!);
114
+ expect(parsed.error.body).to.deep.equal({ reason: 'quota exceeded' });
115
+ });
116
+
117
+ it('should not treat a message that looks like JSON as the headline', () => {
118
+ const raw = '{"error":{"message":"{\\"key\\":\\"value\\"}","code":400}}';
119
+ const result = formatProviderError(raw);
120
+ // The JSON-like message should be skipped; headline falls back to remainder
121
+ expect(result.message).to.equal('{"error":{"message":"{\\"key\\":\\"value\\"}","code":400}}');
122
+ });
123
+
124
+ it('should handle leading status with colon separator', () => {
125
+ const raw = '401: {"error":{"message":"Unauthorized"}}';
126
+ const result = formatProviderError(raw);
127
+ expect(result.status).to.equal('401');
128
+ expect(result.message).to.equal('Unauthorized');
129
+ });
130
+
131
+ it('should extract message from an array of errors', () => {
132
+ const raw = '{"errors":[{"message":"Rate limit exceeded","code":429}]}';
133
+ const result = formatProviderError(raw);
134
+ expect(result.status).to.equal('429');
135
+ expect(result.message).to.equal('Rate limit exceeded');
136
+ });
137
+
138
+ it('should ignore non-3-digit numeric codes', () => {
139
+ const raw = '{"error":{"message":"Something broke","code":42}}';
140
+ const result = formatProviderError(raw);
141
+ expect(result.status).to.be.undefined;
142
+ expect(result.message).to.equal('Something broke');
143
+ });
144
+
145
+ it('should ignore non-numeric status strings', () => {
146
+ const raw = '{"error":{"message":"Invalid key","status":"INVALID_ARGUMENT"}}';
147
+ const result = formatProviderError(raw);
148
+ expect(result.status).to.be.undefined;
149
+ expect(result.message).to.equal('Invalid key');
150
+ });
151
+
152
+ it('should find JSON preceded by non-JSON text', () => {
153
+ const raw = 'Error occurred: {"error":{"message":"connection refused"}}';
154
+ const result = formatProviderError(raw);
155
+ expect(result.message).to.equal('connection refused');
156
+ expect(result.details).to.be.a('string');
157
+ });
158
+
159
+ it('should handle whitespace-only input', () => {
160
+ const result = formatProviderError(' ');
161
+ expect(result.message).to.equal(' ');
162
+ expect(result.status).to.be.undefined;
163
+ });
164
+
165
+ it('should walk into a top-level JSON array', () => {
166
+ const raw = '[{"error":{"message":"first"}},{"error":{"message":"second"}}]';
167
+ const result = formatProviderError(raw);
168
+ // walk visits in order, last message wins
169
+ expect(result.message).to.equal('second');
170
+ });
171
+
172
+ it('should extract 3-digit status from a longer string value', () => {
173
+ const raw = '{"error":{"message":"Too many requests","status":"429 RESOURCE_EXHAUSTED"}}';
174
+ const result = formatProviderError(raw);
175
+ expect(result.status).to.equal('429');
176
+ });
177
+ });
178
+
179
+ describe('formattedProviderErrorToShortString', () => {
180
+
181
+ it('should include status when present', () => {
182
+ const result = formattedProviderErrorToShortString({ status: '401', message: 'Invalid API key', raw: '' });
183
+ expect(result).to.equal('401: Invalid API key');
184
+ });
185
+
186
+ it('should return only headline when no status', () => {
187
+ const result = formattedProviderErrorToShortString({ message: 'Something went wrong', raw: '' });
188
+ expect(result).to.equal('Something went wrong');
189
+ });
190
+ });
@@ -0,0 +1,110 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2026 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ /**
18
+ * Structured form of a provider error string. Provider errors look something
19
+ * like `<httpStatus> <jsonBody>` (Anthropic, OpenAI) or `<jsonBody>` (Gemini).
20
+ * This helper makes a best-effort guess: walk the JSON, grab the deepest
21
+ * `message` string and the first 3-digit `code`/`status` field.
22
+ */
23
+ export interface FormattedProviderError {
24
+ status?: string;
25
+ message: string;
26
+ details?: string;
27
+ raw: string;
28
+ }
29
+
30
+ export function formatProviderError(raw: string | undefined): FormattedProviderError {
31
+ const safeRaw = (raw ?? '').toString();
32
+ const trimmed = safeRaw.trim();
33
+ if (!trimmed) { return { message: safeRaw, raw: safeRaw }; }
34
+
35
+ // Optional leading HTTP status followed by a JSON body, e.g. "401 {...}".
36
+ // The JSON-body anchor avoids misreading arbitrary text that happens to
37
+ // start with 3 digits (e.g. "404 routes were processed") as a status.
38
+ // Real provider errors (Anthropic, OpenAI) always carry a JSON body here.
39
+ const match = trimmed.match(/^(\d{3})\b[\s:-]*(?=[{[])/);
40
+ const remainder = match ? trimmed.slice(match[0].length).trim() : trimmed;
41
+
42
+ const body = parseJson(remainder);
43
+ if (!body || typeof body !== 'object') {
44
+ return { status: match?.[1], message: remainder || safeRaw, raw: safeRaw };
45
+ }
46
+
47
+ let message: string | undefined;
48
+ let code: string | undefined;
49
+ const walk = (node: unknown): void => {
50
+ if (typeof node === 'string') {
51
+ const inner = parseJson(node);
52
+ if (inner && typeof inner === 'object') { walk(inner); }
53
+ return;
54
+ }
55
+ if (!node || typeof node !== 'object') { return; }
56
+ for (const [k, v] of Object.entries(node)) {
57
+ if (k === 'message' && typeof v === 'string' && !looksLikeJson(v)) {
58
+ message = v;
59
+ } else if (!code && (k === 'code' || k === 'status')) {
60
+ code = asStatus(v);
61
+ }
62
+ walk(v);
63
+ }
64
+ };
65
+ walk(body);
66
+
67
+ return {
68
+ status: match?.[1] ?? code,
69
+ message: message ?? remainder,
70
+ details: stringifyUnwrapped(body),
71
+ raw: safeRaw
72
+ };
73
+ }
74
+
75
+ /** Compact one-liner suitable for notification toasts. */
76
+ export function formattedProviderErrorToShortString(e: FormattedProviderError): string {
77
+ return e.status ? `${e.status}: ${e.message}` : e.message;
78
+ }
79
+
80
+ function parseJson(text: string): unknown {
81
+ const start = text.search(/[{[]/);
82
+ if (start < 0) { return undefined; }
83
+ try { return JSON.parse(text.slice(start)); } catch { return undefined; }
84
+ }
85
+
86
+ function looksLikeJson(text: string): boolean {
87
+ const t = text.trimStart();
88
+ return t.startsWith('{') || t.startsWith('[');
89
+ }
90
+
91
+ function asStatus(value: unknown): string | undefined {
92
+ if (typeof value === 'number' && Number.isInteger(value)) {
93
+ const s = String(value);
94
+ return /^\d{3}$/.test(s) ? s : undefined;
95
+ }
96
+ return typeof value === 'string' ? value.match(/^(\d{3})\b/)?.[1] : undefined;
97
+ }
98
+
99
+ /** Pretty-print JSON, transparently parsing any string field that itself contains JSON. */
100
+ function stringifyUnwrapped(value: unknown): string | undefined {
101
+ try {
102
+ return JSON.stringify(value, (_, v) => {
103
+ if (typeof v === 'string' && looksLikeJson(v)) {
104
+ const inner = parseJson(v);
105
+ if (inner && typeof inner === 'object') { return inner; }
106
+ }
107
+ return v;
108
+ }, 2);
109
+ } catch { return undefined; }
110
+ }
@@ -145,6 +145,29 @@ describe('ToolCallChatResponseContentImpl', () => {
145
145
  });
146
146
  });
147
147
 
148
+ describe('updateResult', () => {
149
+ it('should set the result without marking the tool finished', () => {
150
+ const toolCall = new ToolCallChatResponseContentImpl('id', 'tool', '{}', false);
151
+
152
+ toolCall.updateResult('partial');
153
+
154
+ expect(toolCall.result).to.equal('partial');
155
+ expect(toolCall.finished).to.be.false;
156
+ });
157
+
158
+ it('should fire onDidChange so auto-save picks up the partial result', () => {
159
+ const toolCall = new ToolCallChatResponseContentImpl('id', 'tool', '{}', false);
160
+ let fireCount = 0;
161
+ toolCall.onDidChange(() => { fireCount++; });
162
+
163
+ toolCall.updateResult('partial-1');
164
+ toolCall.updateResult('partial-2');
165
+
166
+ expect(fireCount).to.equal(2);
167
+ expect(toolCall.result).to.equal('partial-2');
168
+ });
169
+ });
170
+
148
171
  describe('restored tool calls', () => {
149
172
  it('should have finished=true when restored with a result', () => {
150
173
  const restoredToolCall = new ToolCallChatResponseContentImpl(