@jskit-ai/assistant-runtime 0.1.142 → 0.1.144

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.142",
3
+ "version": "0.1.144",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,15 +11,15 @@
11
11
  "./server/actionIds": "./src/server/actionIds.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/assistant-core": "0.1.147",
15
- "@jskit-ai/database-runtime": "0.1.171",
14
+ "@jskit-ai/assistant-core": "0.1.149",
15
+ "@jskit-ai/database-runtime": "0.1.173",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.169",
20
- "@jskit-ai/http-web": "0.1.16",
21
- "@jskit-ai/kernel": "0.1.171",
22
- "@jskit-ai/shell-web": "0.1.175",
19
+ "@jskit-ai/http-runtime": "0.1.171",
20
+ "@jskit-ai/http-web": "0.1.18",
21
+ "@jskit-ai/kernel": "0.1.173",
22
+ "@jskit-ai/shell-web": "0.1.177",
23
23
  "@tanstack/vue-query": "^5.90.5",
24
24
  "vue": "^3.5.13",
25
25
  "vuetify": "^4.0.0"
@@ -5,14 +5,12 @@ import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/ker
5
5
  import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
6
6
  import {
7
7
  ASSISTANT_STREAM_EVENT_TYPES,
8
- MAX_HISTORY_MESSAGES,
9
8
  MAX_INPUT_CHARS,
10
9
  assistantConversationMessagesQueryKey,
11
10
  assistantConversationsListQueryKey,
12
11
  assistantScopeQueryKey,
13
12
  normalizeAssistantStreamEventType,
14
13
  normalizeConversationStatus as normalizeAssistantConversationStatus,
15
- parseJsonObject,
16
14
  toPositiveInteger
17
15
  } from "@jskit-ai/assistant-core/shared";
18
16
  import {
@@ -23,6 +21,13 @@ import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
23
21
  import { usePagedCollection } from "@jskit-ai/http-web/client/composables/usePagedCollection";
24
22
  import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
25
23
  import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
24
+ import {
25
+ buildHistory,
26
+ buildId,
27
+ interruptPendingToolEvents,
28
+ mapTranscriptEntriesToAssistantState,
29
+ normalizeToolName
30
+ } from "../support/assistantRuntimeState.js";
26
31
  import { insertTextAtSelection } from "../support/composerInputSupport.js";
27
32
  import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
28
33
 
@@ -78,18 +83,6 @@ function writeStoredActiveConversationId(scope = {}, conversationId) {
78
83
  }
79
84
  }
80
85
 
81
- function buildId(prefix = "id") {
82
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
83
- return `${prefix}_${crypto.randomUUID()}`;
84
- }
85
-
86
- return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
87
- }
88
-
89
- function normalizeToolName(value) {
90
- return normalizeText(value) || "tool";
91
- }
92
-
93
86
  function normalizeConversationStatus(value) {
94
87
  return normalizeAssistantConversationStatus(value, {
95
88
  fallback: "unknown"
@@ -110,103 +103,6 @@ function formatConversationStartedAt(value) {
110
103
  return date.toLocaleString();
111
104
  }
112
105
 
113
- function parseToolResultPayload(value) {
114
- return parseJsonObject(value);
115
- }
116
-
117
- function buildHistory(messages) {
118
- const normalizedHistory = (Array.isArray(messages) ? messages : [])
119
- .filter((message) => {
120
- if (!message || typeof message !== "object") {
121
- return false;
122
- }
123
- if (message.kind !== "chat") {
124
- return false;
125
- }
126
- if (message.role !== "user" && message.role !== "assistant") {
127
- return false;
128
- }
129
- if (normalizeText(message.status).toLowerCase() !== "done") {
130
- return false;
131
- }
132
- return Boolean(normalizeText(message.text));
133
- })
134
- .map((message) => ({
135
- role: message.role,
136
- content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
137
- }));
138
-
139
- return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
140
- }
141
-
142
- function mapTranscriptEntriesToAssistantState(entries) {
143
- const sourceEntries = Array.isArray(entries) ? entries : [];
144
- const messages = [];
145
- const toolEventsById = new Map();
146
-
147
- function ensureToolEvent(toolCallId, toolName) {
148
- const key = normalizeText(toolCallId) || buildId("tool_call");
149
- if (toolEventsById.has(key)) {
150
- return toolEventsById.get(key);
151
- }
152
-
153
- const next = {
154
- id: key,
155
- name: normalizeToolName(toolName),
156
- arguments: "",
157
- status: "pending",
158
- result: null,
159
- error: null
160
- };
161
- toolEventsById.set(key, next);
162
- return next;
163
- }
164
-
165
- for (const entry of sourceEntries) {
166
- const role = normalizeText(entry?.role).toLowerCase();
167
- const kind = normalizeText(entry?.kind).toLowerCase();
168
- const metadata = normalizeObject(entry?.metadata);
169
- const transcriptId = normalizeRecordId(entry?.id, { fallback: null });
170
- const messageId = transcriptId ? `transcript_${transcriptId}` : buildId("transcript");
171
-
172
- if (kind === "chat" && (role === "user" || role === "assistant")) {
173
- messages.push({
174
- id: messageId,
175
- role,
176
- kind: "chat",
177
- text: entry?.contentText == null ? "" : String(entry.contentText),
178
- status: "done"
179
- });
180
- continue;
181
- }
182
-
183
- if (kind === "tool_call") {
184
- const toolCallId = normalizeText(metadata.toolCallId) || `tool_call_${messageId}`;
185
- const toolName = normalizeToolName(metadata.tool);
186
- const toolEvent = ensureToolEvent(toolCallId, toolName);
187
- toolEvent.arguments = String(entry?.contentText || "");
188
- toolEvent.status = "pending";
189
- continue;
190
- }
191
-
192
- if (kind === "tool_result") {
193
- const parsedResult = parseToolResultPayload(entry?.contentText);
194
- const toolCallId = normalizeText(metadata.toolCallId || parsedResult.toolCallId) || `tool_result_${messageId}`;
195
- const toolName = normalizeToolName(metadata.tool || parsedResult.tool);
196
- const toolEvent = ensureToolEvent(toolCallId, toolName);
197
- const failed = parsedResult.ok === false || metadata.ok === false;
198
- toolEvent.status = failed ? "failed" : "done";
199
- toolEvent.result = failed ? null : parsedResult.result;
200
- toolEvent.error = failed ? parsedResult.error || metadata.error || null : null;
201
- }
202
- }
203
-
204
- return {
205
- messages,
206
- pendingToolEvents: [...toolEventsById.values()]
207
- };
208
- }
209
-
210
106
  function resolveRuntimePolicy() {
211
107
  const appConfig = getClientAppConfig();
212
108
  const assistantConfig = normalizeObject(appConfig?.assistant);
@@ -724,6 +620,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
724
620
  }
725
621
  } finally {
726
622
  clearTimeout(streamTimeout);
623
+ pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
727
624
  abortController.value = null;
728
625
  isStreaming.value = false;
729
626
  await invalidateConversationScope();
@@ -0,0 +1,140 @@
1
+ import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import {
3
+ MAX_HISTORY_MESSAGES,
4
+ MAX_INPUT_CHARS,
5
+ parseJsonObject
6
+ } from "@jskit-ai/assistant-core/shared";
7
+ import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
8
+
9
+ function buildId(prefix = "id") {
10
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
11
+ return `${prefix}_${crypto.randomUUID()}`;
12
+ }
13
+
14
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
15
+ }
16
+
17
+ function normalizeToolName(value) {
18
+ return normalizeText(value) || "tool";
19
+ }
20
+
21
+ function buildHistory(messages) {
22
+ const normalizedHistory = (Array.isArray(messages) ? messages : [])
23
+ .filter((message) => {
24
+ if (!message || typeof message !== "object") {
25
+ return false;
26
+ }
27
+ if (message.kind !== "chat") {
28
+ return false;
29
+ }
30
+ if (message.role !== "user" && message.role !== "assistant") {
31
+ return false;
32
+ }
33
+ if (normalizeText(message.status).toLowerCase() !== "done") {
34
+ return false;
35
+ }
36
+ const text = normalizeText(message.text);
37
+ if (!text) {
38
+ return false;
39
+ }
40
+ return message.role !== "assistant" || !isAssistantProgressOnlyText(text);
41
+ })
42
+ .map((message) => ({
43
+ role: message.role,
44
+ content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
45
+ }));
46
+
47
+ return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
48
+ }
49
+
50
+ function interruptPendingToolEvents(toolEvents) {
51
+ return (Array.isArray(toolEvents) ? toolEvents : []).map((toolEvent) => {
52
+ if (normalizeText(toolEvent?.status).toLowerCase() !== "pending") {
53
+ return toolEvent;
54
+ }
55
+
56
+ return {
57
+ ...toolEvent,
58
+ status: "interrupted"
59
+ };
60
+ });
61
+ }
62
+
63
+ function mapTranscriptEntriesToAssistantState(entries) {
64
+ const sourceEntries = Array.isArray(entries) ? entries : [];
65
+ const messages = [];
66
+ const toolEventsById = new Map();
67
+
68
+ function ensureToolEvent(toolCallId, toolName) {
69
+ const key = normalizeText(toolCallId) || buildId("tool_call");
70
+ if (toolEventsById.has(key)) {
71
+ return toolEventsById.get(key);
72
+ }
73
+
74
+ const next = {
75
+ id: key,
76
+ name: normalizeToolName(toolName),
77
+ arguments: "",
78
+ status: "pending",
79
+ result: null,
80
+ error: null
81
+ };
82
+ toolEventsById.set(key, next);
83
+ return next;
84
+ }
85
+
86
+ for (const entry of sourceEntries) {
87
+ const role = normalizeText(entry?.role).toLowerCase();
88
+ const kind = normalizeText(entry?.kind).toLowerCase();
89
+ const metadata = normalizeObject(entry?.metadata);
90
+ const transcriptId = normalizeRecordId(entry?.id, { fallback: null });
91
+ const messageId = transcriptId ? `transcript_${transcriptId}` : buildId("transcript");
92
+
93
+ if (kind === "chat" && (role === "user" || role === "assistant")) {
94
+ const text = entry?.contentText == null ? "" : String(entry.contentText);
95
+ if (role === "assistant" && isAssistantProgressOnlyText(text)) {
96
+ continue;
97
+ }
98
+
99
+ messages.push({
100
+ id: messageId,
101
+ role,
102
+ kind: "chat",
103
+ text,
104
+ status: "done"
105
+ });
106
+ continue;
107
+ }
108
+
109
+ if (kind === "tool_call") {
110
+ const toolCallId = normalizeText(metadata.toolCallId) || `tool_call_${messageId}`;
111
+ const toolEvent = ensureToolEvent(toolCallId, metadata.tool);
112
+ toolEvent.arguments = String(entry?.contentText || "");
113
+ toolEvent.status = "pending";
114
+ continue;
115
+ }
116
+
117
+ if (kind === "tool_result") {
118
+ const parsedResult = parseJsonObject(entry?.contentText);
119
+ const toolCallId = normalizeText(metadata.toolCallId || parsedResult.toolCallId) || `tool_result_${messageId}`;
120
+ const toolEvent = ensureToolEvent(toolCallId, metadata.tool || parsedResult.tool);
121
+ const failed = parsedResult.ok === false || metadata.ok === false;
122
+ toolEvent.status = failed ? "failed" : "done";
123
+ toolEvent.result = failed ? null : parsedResult.result;
124
+ toolEvent.error = failed ? parsedResult.error || metadata.error || null : null;
125
+ }
126
+ }
127
+
128
+ return {
129
+ messages,
130
+ pendingToolEvents: interruptPendingToolEvents([...toolEventsById.values()])
131
+ };
132
+ }
133
+
134
+ export {
135
+ buildHistory,
136
+ buildId,
137
+ interruptPendingToolEvents,
138
+ mapTranscriptEntriesToAssistantState,
139
+ normalizeToolName
140
+ };
@@ -5,10 +5,15 @@ import {
5
5
  ASSISTANT_STREAM_EVENT_TYPES
6
6
  } from "@jskit-ai/assistant-core/shared";
7
7
  import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
8
+ import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
8
9
 
9
10
  const MAX_HISTORY_MESSAGES = 20;
10
11
  const MAX_INPUT_CHARS = 8000;
11
- const MAX_TOOL_ROUNDS = 4;
12
+ const MAX_TOOL_ROUNDS = 16;
13
+ const MAX_RECOVERY_PASSES = 3;
14
+ const MAX_TOOL_RESULT_FALLBACK_CHARS = 4000;
15
+ const CLOCK_INSTRUCTION = "For current or relative date and time questions, first use any available authoritative workspace clock action; never infer the current date or time from model knowledge.";
16
+ const COMPLETION_INSTRUCTION = "Do not narrate future work or describe what you are about to do. Either call the required available tool now or provide the completed final answer.";
12
17
 
13
18
  function normalizeConversationId(value) {
14
19
  return normalizeRecordId(value, { fallback: null });
@@ -26,7 +31,7 @@ function normalizeHistory(history = []) {
26
31
  }
27
32
 
28
33
  const content = normalizeText(item.content).slice(0, MAX_INPUT_CHARS);
29
- if (!content) {
34
+ if (!content || (role === "assistant" && isAssistantProgressOnlyText(content))) {
30
35
  return null;
31
36
  }
32
37
 
@@ -152,6 +157,7 @@ function buildSystemPrompt({ targetSurfaceId = "", toolDescriptors = [], workspa
152
157
  "Use tools when they are necessary and only when available.",
153
158
  "Do not mention tools that are not available.",
154
159
  "When answering schema questions, rely only on tool contracts and tool results.",
160
+ CLOCK_INSTRUCTION,
155
161
  workspaceLine,
156
162
  toolSummary,
157
163
  toolContracts
@@ -183,22 +189,17 @@ function buildRecoveryPrompt({ reason = "", toolFailures = [], toolSuccesses = [
183
189
  const failureSuffix = failureSummary ? ` Recent tool failures: ${failureSummary}.` : "";
184
190
  const successSuffix = successSummary ? ` Successful tools: ${successSummary}.` : "";
185
191
  if (normalizedReason === "tool_failure") {
186
- return `One or more tool calls may fail. Continue with available successful results. Do not output function-call markup. Do not mention failed operations unless explicitly asked.${failureSuffix}${successSuffix}`;
192
+ return `One or more tool calls may fail. Continue with available successful results. Do not output function-call markup. Do not mention failed operations unless explicitly asked. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
187
193
  }
188
194
 
189
- return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only.${failureSuffix}${successSuffix}`;
195
+ return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
190
196
  }
191
197
 
192
- function buildRecoveryFallbackAnswer({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
193
- const normalizedReason = normalizeText(reason).toLowerCase();
194
- if (normalizedReason === "tool_failure") {
195
- return buildToolOutcomeFallbackAnswer({
196
- toolFailures,
197
- toolSuccesses
198
- });
199
- }
200
-
201
- return "I reached the tool-call limit for this request. Please narrow the request and I will continue.";
198
+ function buildRecoveryFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
199
+ return buildToolOutcomeFallbackAnswer({
200
+ toolFailures,
201
+ toolSuccesses
202
+ });
202
203
  }
203
204
 
204
205
  function toSafeToolResultText(value) {
@@ -217,26 +218,19 @@ function toSafeToolResultText(value) {
217
218
  }
218
219
 
219
220
  function buildToolOutcomeFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
220
- const successNames = [...new Set(
221
- (Array.isArray(toolSuccesses) ? toolSuccesses : [])
222
- .map((entry) => normalizeText(entry?.name))
223
- .filter(Boolean)
224
- )];
221
+ const successfulResults = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
222
+ .filter((entry) => normalizeText(entry?.name));
225
223
  const hasFailures = Array.isArray(toolFailures) && toolFailures.length > 0;
226
224
 
227
- if (successNames.length > 0) {
228
- const summaryLines = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
229
- .filter((entry) => normalizeText(entry?.name))
230
- .map((entry) => {
231
- const name = normalizeText(entry.name);
232
- const payload = toSafeToolResultText(entry.result);
233
- return `- ${name}:\n\`\`\`json\n${payload}\n\`\`\``;
234
- });
225
+ if (successfulResults.length > 0) {
226
+ const latestSuccess = successfulResults.at(-1);
227
+ const answer = `Latest successful result from ${normalizeText(latestSuccess.name)}:\n${toSafeToolResultText(latestSuccess.result)}`;
228
+ if (answer.length <= MAX_TOOL_RESULT_FALLBACK_CHARS) {
229
+ return answer;
230
+ }
235
231
 
236
- return [
237
- "I used the available successful results:",
238
- ...summaryLines
239
- ].join("\n");
232
+ const suffix = "\n…[truncated]";
233
+ return `${answer.slice(0, MAX_TOOL_RESULT_FALLBACK_CHARS - suffix.length)}${suffix}`;
240
234
  }
241
235
 
242
236
  if (hasFailures) {
@@ -255,7 +249,8 @@ function sanitizeAssistantMessageText(value) {
255
249
  const blockPatterns = [
256
250
  /<[^>\n]*function_calls[^>\n]*>[\s\S]*?<\/[^>\n]*function_calls>/gi,
257
251
  /<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?<\/[^>\n]*tool_calls?[^>\n]*>/gi,
258
- /<[^>\n]*invoke\b[^>\n]*>[\s\S]*?<\/[^>\n]*invoke>/gi
252
+ /<[^>\n]*invoke\b[^>\n]*>[\s\S]*?<\/[^>\n]*invoke>/gi,
253
+ /<(?:analysis|reasoning|think)>[\s\S]*?<\/(?:analysis|reasoning|think)>/gi
259
254
  ];
260
255
  for (const pattern of blockPatterns) {
261
256
  source = source.replace(pattern, " ");
@@ -279,10 +274,10 @@ function sanitizeAssistantMessageText(value) {
279
274
  .join("\n");
280
275
  }
281
276
 
282
- function buildAssistantToolCallMessage({ assistantText = "", toolCalls = [] } = {}) {
277
+ function buildAssistantToolCallMessage(toolCalls = []) {
283
278
  return {
284
279
  role: "assistant",
285
- content: assistantText || "",
280
+ content: "",
286
281
  tool_calls: toolCalls.map((toolCall) => ({
287
282
  id: toolCall.id,
288
283
  type: "function",
@@ -336,96 +331,8 @@ function parseDsmlToolCallsFromText(value = "") {
336
331
  return calls;
337
332
  }
338
333
 
339
- function createDsmlDeltaSanitizer() {
340
- let inTag = false;
341
- let tagBuffer = "";
342
- let suppressedDepth = 0;
343
-
344
- function resolveTagType(rawTag = "") {
345
- const normalizedTag = String(rawTag || "").toLowerCase();
346
- if (normalizedTag.includes("function_calls")) {
347
- return "function_calls";
348
- }
349
- if (normalizedTag.includes("tool_calls")) {
350
- return "tool_calls";
351
- }
352
- if (normalizedTag.includes("invoke")) {
353
- return "invoke";
354
- }
355
- return "";
356
- }
357
-
358
- function processTag(rawTag = "") {
359
- const source = String(rawTag || "");
360
- const inner = source.slice(1, -1).trim();
361
- const isClosing = inner.startsWith("/");
362
- const isSelfClosing = inner.endsWith("/");
363
- const tagType = resolveTagType(inner);
364
-
365
- if (suppressedDepth > 0) {
366
- if (tagType && isClosing) {
367
- suppressedDepth = Math.max(0, suppressedDepth - 1);
368
- } else if (tagType && !isClosing && !isSelfClosing) {
369
- suppressedDepth += 1;
370
- }
371
- return "";
372
- }
373
-
374
- if (!tagType) {
375
- return source;
376
- }
377
-
378
- if (!isClosing && !isSelfClosing) {
379
- suppressedDepth = 1;
380
- }
381
- return "";
382
- }
383
-
384
- function process(delta = "") {
385
- const source = String(delta || "");
386
- if (!source) {
387
- return "";
388
- }
389
-
390
- let output = "";
391
- for (const char of source) {
392
- if (inTag) {
393
- tagBuffer += char;
394
- if (char === ">") {
395
- inTag = false;
396
- output += processTag(tagBuffer);
397
- tagBuffer = "";
398
- }
399
- continue;
400
- }
401
-
402
- if (char === "<") {
403
- inTag = true;
404
- tagBuffer = "<";
405
- continue;
406
- }
407
-
408
- if (suppressedDepth < 1) {
409
- output += char;
410
- }
411
- }
412
-
413
- return output;
414
- }
415
-
416
- function flush() {
417
- return "";
418
- }
419
-
420
- return Object.freeze({
421
- process,
422
- flush
423
- });
424
- }
425
-
426
- async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true, deltaSanitizer = null } = {}) {
334
+ async function consumeCompletionStream(stream) {
427
335
  let assistantText = "";
428
- let streamedAssistantText = "";
429
336
  const toolCallsByIndex = new Map();
430
337
 
431
338
  for await (const chunk of stream) {
@@ -435,19 +342,6 @@ async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true
435
342
  const textDelta = extractTextDelta(delta.content);
436
343
  if (textDelta) {
437
344
  assistantText += textDelta;
438
- if (emitDeltas) {
439
- const safeDelta =
440
- deltaSanitizer && typeof deltaSanitizer.process === "function"
441
- ? String(deltaSanitizer.process(textDelta) || "")
442
- : textDelta;
443
- if (safeDelta) {
444
- streamedAssistantText += safeDelta;
445
- streamWriter.sendAssistantDelta({
446
- type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA,
447
- delta: safeDelta
448
- });
449
- }
450
- }
451
345
  }
452
346
 
453
347
  const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
@@ -489,47 +383,12 @@ async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true
489
383
  }
490
384
  }
491
385
 
492
- if (emitDeltas && deltaSanitizer && typeof deltaSanitizer.flush === "function") {
493
- const trailing = String(deltaSanitizer.flush() || "");
494
- if (trailing) {
495
- streamedAssistantText += trailing;
496
- streamWriter.sendAssistantDelta({
497
- type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA,
498
- delta: trailing
499
- });
500
- }
501
- }
502
-
503
386
  return {
504
387
  assistantText,
505
- streamedAssistantText,
506
388
  toolCalls
507
389
  };
508
390
  }
509
391
 
510
- function mergeAssistantMessageText(streamedText = "", completionText = "") {
511
- const streamed = normalizeText(sanitizeAssistantMessageText(streamedText));
512
- const completion = normalizeText(sanitizeAssistantMessageText(completionText));
513
-
514
- if (!streamed) {
515
- return completion;
516
- }
517
- if (!completion) {
518
- return streamed;
519
- }
520
- if (streamed === completion) {
521
- return streamed;
522
- }
523
- if (completion.startsWith(streamed) || completion.includes(streamed)) {
524
- return completion;
525
- }
526
- if (streamed.startsWith(completion) || streamed.includes(completion)) {
527
- return streamed;
528
- }
529
-
530
- return `${streamed}\n${completion}`;
531
- }
532
-
533
392
  function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
534
393
  const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
535
394
  if (assistantSurface) {
@@ -664,10 +523,9 @@ function createChatService({
664
523
  content: source.input
665
524
  }
666
525
  ];
667
- let streamedAssistantText = "";
668
526
 
669
527
  async function completeWithAssistantMessage(assistantMessageText, { metadata = {} } = {}) {
670
- const normalizedAssistantMessageText = mergeAssistantMessageText(streamedAssistantText, assistantMessageText);
528
+ const normalizedAssistantMessageText = normalizeText(sanitizeAssistantMessageText(assistantMessageText));
671
529
  if (!normalizedAssistantMessageText) {
672
530
  throw new AppError(502, "Assistant returned no output.");
673
531
  }
@@ -820,7 +678,6 @@ function createChatService({
820
678
  }
821
679
 
822
680
  async function recoverWithoutTools({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
823
- const MAX_RECOVERY_PASSES = 3;
824
681
  for (let pass = 0; pass < MAX_RECOVERY_PASSES; pass += 1) {
825
682
  const recoveryMessages = [
826
683
  ...messages,
@@ -839,31 +696,15 @@ function createChatService({
839
696
  tools: [],
840
697
  signal: options.abortSignal
841
698
  });
842
- const completion = await consumeCompletionStream({
843
- stream: completionStream,
844
- streamWriter,
845
- emitDeltas: true,
846
- deltaSanitizer: createDsmlDeltaSanitizer()
847
- });
848
- streamedAssistantText += String(completion.streamedAssistantText || "");
699
+ const completion = await consumeCompletionStream(completionStream);
849
700
 
850
701
  const recoveryToolCalls = completion.toolCalls.filter((entry) => entry.name);
851
702
  if (recoveryToolCalls.length > 0) {
852
- messages.push(
853
- buildAssistantToolCallMessage({
854
- assistantText: completion.assistantText,
855
- toolCalls: recoveryToolCalls
856
- })
857
- );
858
- await executeToolCalls(recoveryToolCalls, {
859
- toolFailures,
860
- toolSuccesses
861
- });
862
703
  continue;
863
704
  }
864
705
 
865
706
  const assistantMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
866
- if (assistantMessageText) {
707
+ if (assistantMessageText && !isAssistantProgressOnlyText(assistantMessageText)) {
867
708
  return completeWithAssistantMessage(assistantMessageText, {
868
709
  metadata: {
869
710
  recoveryReason: reason || "unknown",
@@ -914,18 +755,12 @@ function createChatService({
914
755
  signal: options.abortSignal
915
756
  });
916
757
 
917
- const completion = await consumeCompletionStream({
918
- stream: completionStream,
919
- streamWriter,
920
- emitDeltas: true,
921
- deltaSanitizer: createDsmlDeltaSanitizer()
922
- });
923
- streamedAssistantText += String(completion.streamedAssistantText || "");
758
+ const completion = await consumeCompletionStream(completionStream);
924
759
 
925
760
  const toolCalls = completion.toolCalls.filter((entry) => entry.name);
926
761
  if (toolCalls.length < 1) {
927
762
  const finalMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
928
- if (finalMessageText) {
763
+ if (finalMessageText && !isAssistantProgressOnlyText(finalMessageText)) {
929
764
  return completeWithAssistantMessage(finalMessageText, {
930
765
  metadata: toolFailures.length > 0
931
766
  ? {
@@ -936,23 +771,14 @@ function createChatService({
936
771
  });
937
772
  }
938
773
 
939
- if (toolFailures.length > 0) {
940
- return recoverWithoutTools({
941
- reason: "tool_failure",
942
- toolFailures,
943
- toolSuccesses
944
- });
945
- }
946
-
947
- return completeWithAssistantMessage(completion.assistantText);
774
+ messages.push({
775
+ role: "system",
776
+ content: COMPLETION_INSTRUCTION
777
+ });
778
+ continue;
948
779
  }
949
780
 
950
- messages.push(
951
- buildAssistantToolCallMessage({
952
- assistantText: completion.assistantText,
953
- toolCalls
954
- })
955
- );
781
+ messages.push(buildAssistantToolCallMessage(toolCalls));
956
782
 
957
783
  const roundFailures = await executeToolCalls(toolCalls, {
958
784
  toolFailures,
@@ -0,0 +1,29 @@
1
+ const MAX_PROGRESS_ONLY_TEXT_CHARS = 600;
2
+
3
+ const PROGRESS_SENTENCE_PATTERNS = Object.freeze([
4
+ /^(?:let me|i(?:'|’)ll|i will|i(?:'|’)m going to|i am going to)\s+(?:(?:first|now|quickly)\s+)*(?:analy[sz]e|call|check|confirm|do|execute|fetch|find|inspect|investigate|load|look up|open|prepare|query|read|retrieve|review|run|search|summarize|test|try|use|verify)\b/iu,
5
+ /^(?:analy[sz]ing|calling|checking|confirming|executing|fetching|finding|inspecting|investigating|loading|looking up|opening|preparing|querying|reading|retrieving|reviewing|running|searching|summarizing|testing|trying|using|verifying)\b/iu,
6
+ /^(?:one moment|please wait)\b/iu
7
+ ]);
8
+
9
+ function isAssistantProgressOnlyText(value) {
10
+ const text = String(value || "")
11
+ .replace(/\s+/gu, " ")
12
+ .trim()
13
+ .replace(/^(?:okay|sure)[,;:!\s—-]+/iu, "");
14
+
15
+ if (!text || text.length > MAX_PROGRESS_ONLY_TEXT_CHARS) {
16
+ return false;
17
+ }
18
+
19
+ const sentences = text
20
+ .split(/(?<=[.!?…])\s+/u)
21
+ .map((sentence) => sentence.trim())
22
+ .filter(Boolean);
23
+
24
+ return sentences.length > 0 && sentences.every((sentence) =>
25
+ PROGRESS_SENTENCE_PATTERNS.some((pattern) => pattern.test(sentence))
26
+ );
27
+ }
28
+
29
+ export { isAssistantProgressOnlyText };
@@ -0,0 +1,134 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ buildHistory,
5
+ interruptPendingToolEvents,
6
+ mapTranscriptEntriesToAssistantState
7
+ } from "../src/client/support/assistantRuntimeState.js";
8
+
9
+ test("restored progress narration is neither rendered nor replayed", () => {
10
+ const restored = mapTranscriptEntriesToAssistantState([
11
+ {
12
+ id: "1",
13
+ role: "user",
14
+ kind: "chat",
15
+ contentText: "Find the booking."
16
+ },
17
+ {
18
+ id: "2",
19
+ role: "assistant",
20
+ kind: "chat",
21
+ contentText: "Let me query the bookings."
22
+ },
23
+ {
24
+ id: "3",
25
+ role: "assistant",
26
+ kind: "chat",
27
+ contentText: "The booking is confirmed."
28
+ },
29
+ {
30
+ id: "4",
31
+ role: "assistant",
32
+ kind: "chat",
33
+ contentText: "Let me check. The second booking is also confirmed."
34
+ }
35
+ ]);
36
+
37
+ assert.deepEqual(
38
+ restored.messages.map((message) => message.text),
39
+ [
40
+ "Find the booking.",
41
+ "The booking is confirmed.",
42
+ "Let me check. The second booking is also confirmed."
43
+ ]
44
+ );
45
+ assert.deepEqual(buildHistory(restored.messages), [
46
+ {
47
+ role: "user",
48
+ content: "Find the booking."
49
+ },
50
+ {
51
+ role: "assistant",
52
+ content: "The booking is confirmed."
53
+ },
54
+ {
55
+ role: "assistant",
56
+ content: "Let me check. The second booking is also confirmed."
57
+ }
58
+ ]);
59
+ });
60
+
61
+ test("restored orphaned tool calls are marked interrupted", () => {
62
+ const restored = mapTranscriptEntriesToAssistantState([
63
+ {
64
+ id: "1",
65
+ role: "assistant",
66
+ kind: "tool_call",
67
+ contentText: "{}",
68
+ metadata: {
69
+ toolCallId: "orphaned",
70
+ tool: "action_search"
71
+ }
72
+ },
73
+ {
74
+ id: "2",
75
+ role: "assistant",
76
+ kind: "tool_call",
77
+ contentText: "{}",
78
+ metadata: {
79
+ toolCallId: "completed",
80
+ tool: "action_execute"
81
+ }
82
+ },
83
+ {
84
+ id: "3",
85
+ role: "assistant",
86
+ kind: "tool_result",
87
+ contentText: JSON.stringify({
88
+ ok: true,
89
+ result: {
90
+ id: "41"
91
+ }
92
+ }),
93
+ metadata: {
94
+ toolCallId: "completed",
95
+ tool: "action_execute",
96
+ ok: true
97
+ }
98
+ }
99
+ ]);
100
+
101
+ assert.deepEqual(
102
+ restored.pendingToolEvents.map((event) => ({ id: event.id, status: event.status })),
103
+ [
104
+ { id: "orphaned", status: "interrupted" },
105
+ { id: "completed", status: "done" }
106
+ ]
107
+ );
108
+ });
109
+
110
+ test("live stream cleanup interrupts only tool events that remain pending", () => {
111
+ const finalized = interruptPendingToolEvents([
112
+ {
113
+ id: "pending",
114
+ status: "pending"
115
+ },
116
+ {
117
+ id: "done",
118
+ status: "done"
119
+ },
120
+ {
121
+ id: "failed",
122
+ status: "failed"
123
+ }
124
+ ]);
125
+
126
+ assert.deepEqual(
127
+ finalized.map((event) => ({ id: event.id, status: event.status })),
128
+ [
129
+ { id: "pending", status: "interrupted" },
130
+ { id: "done", status: "done" },
131
+ { id: "failed", status: "failed" }
132
+ ]
133
+ );
134
+ });
@@ -0,0 +1,300 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createChatService } from "../src/server/services/chatService.js";
4
+
5
+ const APP_CONFIG = Object.freeze({
6
+ surfaceDefinitions: {
7
+ assistant: {
8
+ id: "assistant",
9
+ enabled: true,
10
+ requiresWorkspace: false,
11
+ accessPolicyId: "public"
12
+ }
13
+ },
14
+ assistantSurfaces: {
15
+ assistant: {
16
+ settingsSurfaceId: "assistant",
17
+ configScope: "global"
18
+ }
19
+ }
20
+ });
21
+
22
+ function textCompletion(text) {
23
+ return { text };
24
+ }
25
+
26
+ function toolCompletion(name, sequence, { text = "" } = {}) {
27
+ return {
28
+ text,
29
+ toolCall: {
30
+ id: `tool_call_${sequence}`,
31
+ name,
32
+ arguments: JSON.stringify({ sequence })
33
+ }
34
+ };
35
+ }
36
+
37
+ async function* completionStream(completion = {}) {
38
+ if (completion.text) {
39
+ yield {
40
+ choices: [
41
+ {
42
+ delta: {
43
+ content: completion.text
44
+ }
45
+ }
46
+ ]
47
+ };
48
+ }
49
+
50
+ if (completion.toolCall) {
51
+ yield {
52
+ choices: [
53
+ {
54
+ delta: {
55
+ tool_calls: [
56
+ {
57
+ index: 0,
58
+ id: completion.toolCall.id,
59
+ function: {
60
+ name: completion.toolCall.name,
61
+ arguments: completion.toolCall.arguments
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ }
67
+ ]
68
+ };
69
+ }
70
+ }
71
+
72
+ function createHarness(completions, { executeToolCall = null } = {}) {
73
+ const pendingCompletions = [...completions];
74
+ const completionRequests = [];
75
+ const transcriptMessages = [];
76
+ const completedConversations = [];
77
+ const executedTools = [];
78
+ const streamEvents = [];
79
+ const tools = ["action_search", "action_contract", "action_execute"].map((name) => ({
80
+ name,
81
+ parameters: {
82
+ type: "object"
83
+ },
84
+ outputSchema: {
85
+ type: "object"
86
+ }
87
+ }));
88
+
89
+ const chatService = createChatService({
90
+ aiClientFactory: {
91
+ resolveClient() {
92
+ return {
93
+ enabled: true,
94
+ provider: "test",
95
+ defaultModel: "test-model",
96
+ async createChatCompletionStream(request) {
97
+ completionRequests.push({
98
+ messages: structuredClone(request.messages),
99
+ tools: structuredClone(request.tools)
100
+ });
101
+ const completion = pendingCompletions.shift();
102
+ assert.ok(completion, "Expected a queued assistant completion.");
103
+ return completionStream(completion);
104
+ }
105
+ };
106
+ }
107
+ },
108
+ transcriptService: {
109
+ async createConversationForTurn() {
110
+ return {
111
+ conversation: {
112
+ id: "conversation_1"
113
+ }
114
+ };
115
+ },
116
+ async appendMessage(_surface, _conversationId, message) {
117
+ transcriptMessages.push(structuredClone(message));
118
+ },
119
+ async completeConversation(_surface, _conversationId, completion) {
120
+ completedConversations.push(structuredClone(completion));
121
+ }
122
+ },
123
+ serviceToolCatalog: {
124
+ resolveToolSet() {
125
+ return { tools };
126
+ },
127
+ toOpenAiToolSchema(tool) {
128
+ return {
129
+ type: "function",
130
+ function: {
131
+ name: tool.name,
132
+ parameters: tool.parameters
133
+ }
134
+ };
135
+ },
136
+ async executeToolCall(request) {
137
+ executedTools.push(structuredClone(request));
138
+ if (typeof executeToolCall === "function") {
139
+ return executeToolCall(request, executedTools.length);
140
+ }
141
+ return {
142
+ ok: true,
143
+ result: {
144
+ sequence: executedTools.length,
145
+ tool: request.toolName
146
+ }
147
+ };
148
+ }
149
+ },
150
+ assistantConfigService: {
151
+ async resolveSystemPrompt() {
152
+ return "";
153
+ }
154
+ },
155
+ appConfig: APP_CONFIG
156
+ });
157
+
158
+ const streamWriter = {};
159
+ for (const method of [
160
+ "sendMeta",
161
+ "sendAssistantDelta",
162
+ "sendAssistantMessage",
163
+ "sendToolCall",
164
+ "sendToolResult",
165
+ "sendError",
166
+ "sendDone"
167
+ ]) {
168
+ streamWriter[method] = (payload) => {
169
+ streamEvents.push({ method, payload: structuredClone(payload) });
170
+ };
171
+ }
172
+
173
+ async function run(input = "Help me") {
174
+ return chatService.streamChat(
175
+ {
176
+ targetSurfaceId: "assistant",
177
+ messageId: "message_1",
178
+ input,
179
+ history: []
180
+ },
181
+ {
182
+ context: {
183
+ actor: {
184
+ id: "user_1"
185
+ }
186
+ },
187
+ streamWriter
188
+ }
189
+ );
190
+ }
191
+
192
+ return {
193
+ completedConversations,
194
+ completionRequests,
195
+ executedTools,
196
+ run,
197
+ streamEvents,
198
+ transcriptMessages
199
+ };
200
+ }
201
+
202
+ function assistantMessages(events) {
203
+ return events
204
+ .filter((event) => event.method === "sendAssistantMessage")
205
+ .map((event) => event.payload.text);
206
+ }
207
+
208
+ test("progress-only output is retried silently and current-time prompts require a workspace clock", async () => {
209
+ const harness = createHarness([
210
+ textCompletion("Let me query the current time."),
211
+ textCompletion("<think>This must remain private.</think>\nIt is Tuesday in the workspace timezone.")
212
+ ]);
213
+
214
+ const result = await harness.run("What day is it today?");
215
+
216
+ assert.equal(result.status, "completed");
217
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
218
+ assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
219
+ assert.match(
220
+ harness.completionRequests[0].messages[0].content,
221
+ /first use any available authoritative workspace clock action/u
222
+ );
223
+ assert.equal(
224
+ harness.completionRequests[1].messages.some((message) => message.content === "Let me query the current time."),
225
+ false
226
+ );
227
+ assert.equal(
228
+ harness.completionRequests[1].messages.some((message) => /Either call the required available tool now/u.test(message.content)),
229
+ true
230
+ );
231
+ assert.deepEqual(
232
+ harness.transcriptMessages
233
+ .filter((message) => message.kind === "chat")
234
+ .map((message) => message.contentText),
235
+ ["What day is it today?", "It is Tuesday in the workspace timezone."]
236
+ );
237
+ });
238
+
239
+ test("native search, contract, and execution workflows can exceed four silent tool rounds", async () => {
240
+ const harness = createHarness([
241
+ toolCompletion("action_search", 1, { text: "I'll search first." }),
242
+ toolCompletion("action_contract", 2, { text: "Let me inspect that contract." }),
243
+ toolCompletion("action_execute", 3, { text: "I'll execute it now." }),
244
+ toolCompletion("action_contract", 4),
245
+ toolCompletion("action_execute", 5),
246
+ textCompletion("The requested operation completed successfully.")
247
+ ]);
248
+
249
+ await harness.run();
250
+
251
+ assert.deepEqual(
252
+ harness.executedTools.map((request) => request.toolName),
253
+ ["action_search", "action_contract", "action_execute", "action_contract", "action_execute"]
254
+ );
255
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["The requested operation completed successfully."]);
256
+ assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
257
+ const assistantToolMessages = harness.completionRequests
258
+ .flatMap((request) => request.messages)
259
+ .filter((message) => Array.isArray(message.tool_calls));
260
+ assert.ok(assistantToolMessages.length > 0);
261
+ assert.equal(assistantToolMessages.every((message) => message.content === ""), true);
262
+ });
263
+
264
+ test("tool-loop exhaustion returns the latest successful result with a hard output cap", async () => {
265
+ const mainRounds = Array.from({ length: 16 }, (_, index) =>
266
+ toolCompletion("action_execute", index + 1)
267
+ );
268
+ const harness = createHarness(
269
+ [
270
+ ...mainRounds,
271
+ textCompletion("Let me prepare the answer."),
272
+ textCompletion("I'll summarize the result."),
273
+ textCompletion("Checking the final output.")
274
+ ],
275
+ {
276
+ executeToolCall(_request, sequence) {
277
+ return {
278
+ ok: true,
279
+ result: {
280
+ sequence,
281
+ payload: sequence === 16 ? "x".repeat(10_000) : "ok"
282
+ }
283
+ };
284
+ }
285
+ }
286
+ );
287
+
288
+ await harness.run();
289
+
290
+ const finalMessages = assistantMessages(harness.streamEvents);
291
+ assert.equal(harness.executedTools.length, 16);
292
+ assert.equal(harness.completionRequests.length, 19);
293
+ assert.equal(finalMessages.length, 1);
294
+ assert.ok(finalMessages[0].length <= 4000);
295
+ assert.match(finalMessages[0], /Latest successful result from action_execute/u);
296
+ assert.match(finalMessages[0], /"sequence": 16/u);
297
+ assert.match(finalMessages[0], /…\[truncated\]$/u);
298
+ assert.doesNotMatch(finalMessages[0], /Please narrow the request/u);
299
+ assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
300
+ });