@openchambery/web 1.19.15-beta.4 → 1.19.15

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 (52) hide show
  1. package/dist/assets/{AssistantView-bxT9mr97.js → AssistantView-xdL1EvpR.js} +2 -2
  2. package/dist/assets/{DiagramView-DJPrdklU.js → DiagramView-DPgkYU-_.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-CP7UVZdr.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CBdwjEMH.js → MarkdownRendererImpl-BB1Q1RWk.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-CHwYXkzv.js → MarkstreamRendererImpl-BjQrUIcq.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-DLmATOi0.js → MobileDetailNavigation-Cyzs6cuR.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-BbvoAeXg.js → MobileShareBridge-A-Z6jMUn.js} +1 -1
  8. package/dist/assets/MobileSurface-DjDa20gx.js +2 -0
  9. package/dist/assets/{MultiRunWindow-B7XPMkdc.js → MultiRunWindow-Cg5x-kJV.js} +1 -1
  10. package/dist/assets/{SettingsView-DABJm3u8.js → SettingsView-CYrTHDdp.js} +1 -1
  11. package/dist/assets/{SettingsWindow-D7n0m-52.js → SettingsWindow-CF-0Zgxr.js} +1 -1
  12. package/dist/assets/{TerminalView-DERnAkUi.js → TerminalView-CShou18D.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-Bx3plCCc.js → ToolOutputDialog-BvmxlNOa.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-BdHiNL-J.js → appThemeRegistry-iqSTUpPj.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-B3sIMQCF.js → fileMentionTouchSelection-CddGH2aH.js} +1 -1
  16. package/dist/assets/{gitApi-CDc5ynsh.js → gitApi-BwuaHHRQ.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-3yNePddt.js → insertionBoundaries-og6NBgdn.js} +1 -1
  18. package/dist/assets/{main-5GGJlyEB.js → main-31g3JiQx.js} +4 -4
  19. package/dist/assets/{main-C_21O0IU.js → main-VD-QBM47.js} +2 -2
  20. package/dist/assets/{miniChat-Ti_tFK96.js → miniChat-DwCMTamY.js} +2 -2
  21. package/dist/assets/{mobile-BUYP8DBL.js → mobile-C7q1yowe.js} +2 -2
  22. package/dist/assets/{multirun-CXazK6qX.js → multirun-DcN5T0lN.js} +1 -1
  23. package/dist/assets/{projectMeta-sXx-r5jn.js → projectMeta-moNJYCK4.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-CLx8jNP4.js → renderElectronMiniChatApp-DVkgKA6A.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BGKxwMFU.js → renderMobileApp-BgEXZhCO.js} +4 -4
  26. package/dist/assets/{runtimeConfig-YCy1Bcb2.js → runtimeConfig-B49cxtgI.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-Dh3YvmRM.js → runtimeEndpointReset-9K_Dj1qR.js} +1 -1
  28. package/dist/assets/{sessionLookup-BzkA2pF0.js → sessionLookup-m3h8c4xR.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-BLw4Av7h.js → useAppFontEffects-DRFi4TCE.js} +5 -5
  30. package/dist/assets/{useDesktopWindowControlsLayout-DNDIYvwP.js → useDesktopWindowControlsLayout-BdNR3zMN.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-B_3sINMx.js → useEffectiveDirectory-COoPja_2.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-BBVnSn9p.js → useMobileNavigationStore-Cn0-ozUB.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-SsGxsSRB.js → useSessionAutoCleanup-XwgyzsEd.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-WCuVI6O_.js → useWorkerHighlightedLines-GgcqEoKY.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/lib/assistants/DOCUMENTATION.md +31 -6
  40. package/server/lib/assistants/contact-tools.js +1 -16
  41. package/server/lib/assistants/contact-tools.test.js +0 -27
  42. package/server/lib/assistants/harness.js +140 -133
  43. package/server/lib/assistants/harness.test.js +207 -43
  44. package/server/lib/assistants/service.js +10 -33
  45. package/server/lib/assistants/service.test.js +75 -3
  46. package/server/lib/llm/DOCUMENTATION.md +8 -1
  47. package/server/lib/llm/completions.js +5 -0
  48. package/server/lib/llm/completions.test.js +19 -0
  49. package/server/lib/llm/generate.js +5 -1
  50. package/server/lib/llm/generate.test.js +23 -0
  51. package/dist/assets/MarkdownRenderer-4gjE7aM5.js +0 -2
  52. package/dist/assets/MobileSurface-BBOx43VU.js +0 -2
@@ -1,35 +1,17 @@
1
1
  import { Agent } from '@earendil-works/pi-agent-core';
2
2
  import { isContactSpokenPreamble, splitContactBubbles } from './bubbles.js';
3
3
  import {
4
- ASSIGN_SESSION_TOOL_NAME,
5
- CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
6
- CLEAR_CHAT_HISTORY_TOOL_NAME,
7
- confirmBubbleAfterContactReset,
8
4
  contactTurnClearedChatHistory,
9
5
  contactTurnHasSuccessfulReset,
10
6
  contactTurnHasToolResult,
11
- CREATE_ASSISTANT_TOOL_NAME,
12
7
  detectRequestedContactTools,
13
8
  extractContactCardsFromMessages,
14
9
  formatContactToolsPrompt,
15
10
  formatConnectedModelsPrompt,
16
11
  formatRegisteredProjectsPrompt,
17
- GET_ASSISTANT_SETTINGS_TOOL_NAME,
18
- MESSAGE_ASSISTANT_TOOL_NAME,
19
12
  MISSED_FENCE_RETRY_USER_TEXT,
20
- MISSED_TOOL_FAILURE_BUBBLE,
21
- NEW_CONVERSATION_CONFIRM_BUBBLE,
22
- NEW_CONVERSATION_TOOL_NAME,
23
13
  parseContactToolCalls,
24
- READ_SESSION_TOOL_NAME,
25
- SCHEDULE_TASK_TOOL_NAME,
26
- STOP_SESSION_TOOL_NAME,
27
- STEER_SESSION_TOOL_NAME,
28
- ARCHIVE_SESSION_TOOL_NAME,
29
- DELETE_SESSION_TOOL_NAME,
30
14
  stripContactToolFences,
31
- UPDATE_DEFAULT_PROMPT_TOOL_NAME,
32
- WATCH_SESSION_TOOL_NAME,
33
15
  } from './contact-tools.js';
34
16
  import {
35
17
  createPiCodingRuntime,
@@ -121,7 +103,9 @@ export const CONTACT_SYSTEM_PROMPT = [
121
103
  'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
122
104
  'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
123
105
  'Always reply in {{LANGUAGE}} — the user\'s current interface language — even when the user writes in another language, unless they explicitly ask for a different one.',
124
- 'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
106
+ 'Keep private reasoning and tool traces private. Communicate your next concrete action, verified milestones, and blockers in short natural messages in {{LANGUAGE}}.',
107
+ 'Before starting work, send a short message explaining the next action. During multi-step work, send a message when a meaningful result arrives or the direction changes. Publish these messages during execution; keep the final answer to the remaining outcome instead of repeating earlier updates.',
108
+ 'Public message protocol: put each user-facing progress message at the START of your response in its own openchamber-message JSON fence, with a single-line JSON object {"text":"Your short message"}. Then emit the next tool call or final answer. A closed message fence is delivered immediately while you are still generating. Include only text intended for the user. Tool results are evidence for you to summarize in your own words; never copy their stock confirmation wording into your reply.',
125
109
  'Do not expose tool traces, Activity, or editor actions.',
126
110
  'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
127
111
  'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 查看助手设定 / 默认提示词 means get_assistant_settings (pass to="Name" for another assistant), 改默认提示词 / 设置人设 / 改某助手的默认提示词 means update_default_prompt (persists that assistant\'s settings, later turns only; pass to="OpenCode 配置助手" to edit another contact without changing this one), 建助理 means create_assistant, 建会话 / 开个新会话 / 继续会话 means assign_session, 监听会话 means watch_session, 停止/取消/打断会话 means stop_session, 插话 means steer_session, 归档会话 means archive_session, 删除会话 means delete_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
@@ -193,10 +177,9 @@ const assistantMessage = (model, text, stopReason, errorMessage) => ({
193
177
  /**
194
178
  * streamFn for pi-agent-core. Calls OpenChamber completions (public HTTP stays
195
179
  * non-streaming). In-process callers may pass onTextDelta/globalEventHub for
196
- * the throwaway generate path. Raw tokens are never painted as contact bubbles
197
- * (they are often chain-of-thought). User-facing bubbles emit only after parse:
198
- * no-tool replies as stripped bubbles; tool calls stay silent until the tool
199
- * confirm. Must not throw — encode failures on the event stream.
180
+ * the throwaway generate path. Complete public prefix records publish during
181
+ * generation; ordinary raw tokens stay private. Legacy prose and final replies
182
+ * publish after parsing. Failures are encoded on the event stream.
200
183
  */
201
184
  const completionFileParts = (value) => (Array.isArray(value) ? value : [])
202
185
  .filter((part) => part?.type === 'file' && typeof part.mime === 'string' && typeof part.url === 'string')
@@ -210,32 +193,54 @@ const completionFileParts = (value) => (Array.isArray(value) ? value : [])
210
193
  /** Fence start (openchamber-tool or generic ```) — stop live bubble deltas. */
211
194
  const CONTACT_FENCE_START = /```/;
212
195
 
213
- /** Tool confirms that are themselves the user-facing bubble (no card). */
214
- const TOOL_TEXT_BUBBLE_TOOLS = new Set([
215
- STOP_SESSION_TOOL_NAME,
216
- STEER_SESSION_TOOL_NAME,
217
- ARCHIVE_SESSION_TOOL_NAME,
218
- DELETE_SESSION_TOOL_NAME,
219
- NEW_CONVERSATION_TOOL_NAME,
220
- CLEAR_CHAT_HISTORY_TOOL_NAME,
221
- UPDATE_DEFAULT_PROMPT_TOOL_NAME,
222
- GET_ASSISTANT_SETTINGS_TOOL_NAME,
223
- ]);
224
-
225
- /** Card / side-effect tools: never paint English toolText into the transcript. */
226
- const CARD_SIDE_EFFECT_TOOLS = new Set([
227
- ASSIGN_SESSION_TOOL_NAME,
228
- WATCH_SESSION_TOOL_NAME,
229
- STOP_SESSION_TOOL_NAME,
230
- CREATE_ASSISTANT_TOOL_NAME,
231
- SCHEDULE_TASK_TOOL_NAME,
232
- MESSAGE_ASSISTANT_TOOL_NAME,
233
- ]);
196
+ // Only explicitly public, complete prefix records may publish before completion.
197
+ // Retain at most one bounded incomplete record; ordinary text and tool JSON stay private.
198
+ function createPublicMessageReader(publish) {
199
+ const marker = '```openchamber-message';
200
+ let pending = '';
201
+ let stopped = false;
202
+ return {
203
+ push(delta) {
204
+ if (stopped || typeof delta !== 'string') return;
205
+ pending += delta;
206
+ while (true) {
207
+ pending = pending.trimStart();
208
+ if (!pending || marker.startsWith(pending)) return;
209
+ if (!pending.startsWith(marker)) { stopped = true; return; }
210
+ const match = pending.match(/^```openchamber-message[ \t]*\r?\n([^\n]+)\r?\n```/u);
211
+ if (match && match[0].length > 8192) { stopped = true; return; }
212
+ if (!match) {
213
+ if (pending.length > 8192) stopped = true;
214
+ return;
215
+ }
216
+ let value;
217
+ try {
218
+ value = JSON.parse(match[1]);
219
+ if (typeof value?.text !== 'string' || !value.text.trim() || value.text.length > 2000) {
220
+ stopped = true;
221
+ return;
222
+ }
223
+ } catch { stopped = true; return; }
224
+ publish(value.text.trim());
225
+ pending = pending.slice(match[0].length);
226
+ }
227
+ },
228
+ };
229
+ }
234
230
 
235
- /** Lookup payloads for the model only — never paint quoted JSON as a user bubble. */
236
- const MODEL_ONLY_LOOKUP_TOOLS = new Set([
237
- READ_SESSION_TOOL_NAME,
238
- ]);
231
+ function stripPublicMessages(text) {
232
+ let rest = text.trimStart();
233
+ while (rest.startsWith('```openchamber-message')) {
234
+ const match = rest.match(/^```openchamber-message[ \t]*\r?\n([^\n]+)\r?\n```/u);
235
+ if (!match || match[0].length > 8192) throw new Error('Invalid public message record');
236
+ const value = JSON.parse(match[1]);
237
+ if (typeof value?.text !== 'string' || !value.text.trim() || value.text.length > 2000) {
238
+ throw new Error('Invalid public message text');
239
+ }
240
+ rest = rest.slice(match[0].length).trimStart();
241
+ }
242
+ return rest;
243
+ }
239
244
 
240
245
  const assistantTextParts = (message) => (Array.isArray(message?.content) ? message.content : [])
241
246
  .filter((part) => part?.type === 'text' && typeof part.text === 'string')
@@ -257,7 +262,7 @@ export function projectStreamedContactTurnBubbles(messages, turnStart = 0) {
257
262
  const hasTool = parts.some((part) => part?.type === 'toolCall');
258
263
  const text = assistantTextParts(message).trim();
259
264
  if (!text) continue;
260
- if (hasTool && !isContactSpokenPreamble(text)) continue;
265
+ if (hasTool && message.contactPublicText !== true && !isContactSpokenPreamble(text)) continue;
261
266
  for (const bubble of splitContactBubbles(text)) bubbles.push(bubble);
262
267
  }
263
268
  return bubbles;
@@ -360,11 +365,22 @@ export function createContactStreamFn(createChatCompletion, {
360
365
  signal = null,
361
366
  /** Fixed Agent message length before this contact turn (prior history only). */
362
367
  turnMessageStart = 0,
368
+ variant = null,
363
369
  } = {}) {
364
370
  let callSequence = 0;
365
371
  const turnStart = Number.isFinite(Number(turnMessageStart)) ? Math.max(0, Number(turnMessageStart)) : 0;
366
372
  return (model, context) => {
367
373
  const stream = createAssistantMessageEventStream();
374
+ const publicBubbles = [];
375
+ const baseIndex = projectStreamedContactTurnBubbles(context.messages, turnStart).length;
376
+ const publishPublicMessage = (text) => {
377
+ signal?.throwIfAborted();
378
+ for (const bubble of splitContactBubbles(text)) {
379
+ const index = baseIndex + publicBubbles.length;
380
+ publicBubbles.push(bubble);
381
+ onBubbleDelta?.(index, bubble, true);
382
+ }
383
+ };
368
384
  const run = async () => {
369
385
  try {
370
386
  const fallbackFiles = completionFileParts(pendingFileParts);
@@ -427,6 +443,8 @@ export function createContactStreamFn(createChatCompletion, {
427
443
  // Real results stay in messages, so already completed work is not replayed.
428
444
  for (let attempt = 0; ; attempt += 1) {
429
445
  signal?.throwIfAborted();
446
+ const publicStart = publicBubbles.length;
447
+ const publicReader = createPublicMessageReader(publishPublicMessage);
430
448
  const result = await createChatCompletion({
431
449
  signal,
432
450
  body: {
@@ -435,15 +453,28 @@ export function createContactStreamFn(createChatCompletion, {
435
453
  ? model.name.split('/')[0]
436
454
  : undefined,
437
455
  modelID: model.id,
456
+ ...(variant ? { variant } : {}),
438
457
  messages: [...messages],
439
458
  },
440
459
  onTextDelta: (delta) => {
460
+ if (signal?.aborted) return;
461
+ if (typeof delta === 'string') {
462
+ publicReader.push(delta);
463
+ }
441
464
  if (typeof delta === 'string' && delta && typeof onTextDelta === 'function') onTextDelta(delta);
442
465
  },
443
466
  globalEventHub,
444
467
  });
445
468
  signal?.throwIfAborted();
446
469
  text = result?.completion?.choices?.[0]?.message?.content ?? result?.text ?? '';
470
+ const finalPublic = [];
471
+ createPublicMessageReader((value) => finalPublic.push(...splitContactBubbles(value))).push(text);
472
+ const observedPublic = publicBubbles.slice(publicStart);
473
+ if (observedPublic.some((value, index) => finalPublic[index] !== value)) {
474
+ throw new Error('Model changed an already published public message');
475
+ }
476
+ for (const value of finalPublic.slice(observedPublic.length)) publishPublicMessage(value);
477
+ text = stripPublicMessages(text);
447
478
  const finalResponse = parseFinalResponse(text);
448
479
  finalText = finalResponse?.text ?? null;
449
480
  // A final answer is opaque text; quoted tool examples in it cannot run.
@@ -462,14 +493,17 @@ export function createContactStreamFn(createChatCompletion, {
462
493
  if (attempt >= 2) throw new Error('Assistant response protocol failed after two corrections; the task was not confirmed complete.');
463
494
  const rejected = parsed.toolCalls.filter((call) => isPiCodingToolName(call.name));
464
495
  if (rejected.length > 0) rejectedCodingCalls = rejected.map(({ name, arguments: args }) => ({ name, arguments: args }));
496
+ if (publicBubbles.length > publicStart) {
497
+ messages.push({ role: 'assistant', content: publicBubbles.slice(publicStart).join('\n\n') });
498
+ }
465
499
  // Never replay rejected prose: it may contain fabricated user/tool results.
466
500
  messages.push(
467
501
  { role: 'user', content: `OpenChamber response protocol correction: ${protocolError} No tool calls from the rejected response executed. ${rejectedCodingCalls.length ? `Unexecuted proposed calls (not results): ${JSON.stringify(rejectedCodingCalls)}.` : ''} Continue the remaining work using the supplied real results; do not repeat completed operations. ${requiresFinal ? WORKSPACE_RESPONSE_PROTOCOL : 'Emit the corrected openchamber-tool call.'}` },
468
502
  );
469
503
  }
470
504
  // Continue turn-global bubble indices from already-streamed turn messages.
471
- const baseIndex = projectStreamedContactTurnBubbles(context.messages, turnStart).length;
472
- const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex });
505
+ const nextIndex = baseIndex + publicBubbles.length;
506
+ const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex: nextIndex });
473
507
  if (parsed.toolCalls.length > 0) {
474
508
  const toolCalls = parsed.toolCalls.map((call) => ({
475
509
  type: 'toolCall',
@@ -479,22 +513,24 @@ export function createContactStreamFn(createChatCompletion, {
479
513
  }));
480
514
  const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
481
515
  const spokenBubbles = spoken ? splitContactBubbles(spoken) : [];
482
- const content = spoken
483
- ? [{ type: 'text', text: spoken }, ...toolCalls]
516
+ const publicText = [...publicBubbles, ...spokenBubbles].join('\n\n');
517
+ const content = publicText
518
+ ? [{ type: 'text', text: publicText }, ...toolCalls]
484
519
  : toolCalls;
485
520
  const partial = {
486
521
  ...assistantMessage(model, spoken, 'toolUse'),
487
522
  content,
523
+ contactPublicText: true,
488
524
  };
489
525
  if (spokenBubbles.length > 0) bubbleTracker.finish(spokenBubbles);
490
526
  stream.push({ type: 'start', partial });
491
- if (spoken) {
527
+ if (publicText) {
492
528
  stream.push({ type: 'text_start', contentIndex: 0, partial });
493
- stream.push({ type: 'text_delta', contentIndex: 0, delta: spoken, partial });
494
- stream.push({ type: 'text_end', contentIndex: 0, content: spoken, partial });
529
+ stream.push({ type: 'text_delta', contentIndex: 0, delta: publicText, partial });
530
+ stream.push({ type: 'text_end', contentIndex: 0, content: publicText, partial });
495
531
  }
496
532
  toolCalls.forEach((toolCall, index) => {
497
- const toolIndex = index + (spoken ? 1 : 0);
533
+ const toolIndex = index + (publicText ? 1 : 0);
498
534
  stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
499
535
  stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
500
536
  stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
@@ -507,7 +543,7 @@ export function createContactStreamFn(createChatCompletion, {
507
543
  const replyBubbles = splitContactBubbles(chatText);
508
544
  if (bubbleGapMs > 0 && replyBubbles.length > 1) {
509
545
  for (let index = 0; index < replyBubbles.length; index += 1) {
510
- onBubbleDelta?.(baseIndex + index, replyBubbles[index], true);
546
+ onBubbleDelta?.(nextIndex + index, replyBubbles[index], true);
511
547
  if (index < replyBubbles.length - 1) {
512
548
  await new Promise((resolve) => setTimeout(resolve, bubbleGapMs));
513
549
  }
@@ -515,7 +551,7 @@ export function createContactStreamFn(createChatCompletion, {
515
551
  } else {
516
552
  bubbleTracker.finish(replyBubbles);
517
553
  }
518
- const partial = assistantMessage(model, chatText, 'stop');
554
+ const partial = assistantMessage(model, [...publicBubbles, ...replyBubbles].join('\n\n'), 'stop');
519
555
  stream.push({ type: 'start', partial });
520
556
  stream.push({ type: 'text_start', contentIndex: 0, partial });
521
557
  stream.push({ type: 'text_delta', contentIndex: 0, delta: chatText, partial });
@@ -555,25 +591,11 @@ const extractAssistantText = (messages) => {
555
591
  return '';
556
592
  };
557
593
 
558
- const extractToolResultText = (messages) => {
559
- for (let index = messages.length - 1; index >= 0; index -= 1) {
560
- const message = messages[index];
561
- if (message?.role !== 'toolResult') continue;
562
- const text = (message.content || [])
563
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
564
- .map((part) => part.text)
565
- .join('')
566
- .trim();
567
- if (text) return text;
568
- }
569
- return '';
570
- };
571
-
572
594
  /**
573
595
  * Final turn bubbles reuse the same projection as live onBubbleDelta indices
574
596
  * (every spoken preamble + final assistant text; independent messages keep
575
- * duplicate text). Tool-result-only confirms (never streamed mid-turn) append
576
- * after that published prefix. Reset collapses to a unique confirm separately.
597
+ * duplicate text). Tool results remain model input. Reset uses a fresh model
598
+ * confirmation with the completed reset result as its only task context.
577
599
  */
578
600
  const extractContactTurnOutcome = (messages) => {
579
601
  const slice = Array.isArray(messages) ? messages : [];
@@ -581,38 +603,8 @@ const extractContactTurnOutcome = (messages) => {
581
603
  const hasTool = contactTurnHasToolResult(slice);
582
604
  // Same rules as live SSE indices — keep every independent spoken/final bubble.
583
605
  const published = projectStreamedContactTurnBubbles(slice, 0);
584
- const lastToolName = [...slice].reverse().find((message) => message?.role === 'toolResult')?.toolName;
585
- const coding = isPiCodingToolName(lastToolName);
586
- const toolText = extractToolResultText(slice);
587
- const assistant = extractAssistantText(slice);
588
- // Tool-result text is never projected mid-turn; append only when it is the
589
- // user-facing confirm (confirm-only / list tools). Spoken + assistant text
590
- // already live in `published`. Card/side-effect and read_session never paint
591
- // English tool payloads.
592
- let toolOnlyConfirm = '';
593
- if (hasTool && !coding) {
594
- if (TOOL_TEXT_BUBBLE_TOOLS.has(lastToolName)) {
595
- toolOnlyConfirm = toolText || '';
596
- if (!toolOnlyConfirm && published.length === 0) toolOnlyConfirm = assistant || '';
597
- } else if (CARD_SIDE_EFFECT_TOOLS.has(lastToolName) || MODEL_ONLY_LOOKUP_TOOLS.has(lastToolName)) {
598
- toolOnlyConfirm = '';
599
- } else {
600
- // list_projects / list_sessions and other app tools keep toolText.
601
- toolOnlyConfirm = toolText || '';
602
- if (!toolOnlyConfirm && published.length === 0) toolOnlyConfirm = assistant || '';
603
- }
604
- } else if (hasTool && coding && published.length === 0) {
605
- toolOnlyConfirm = toolText || '';
606
- }
606
+ extractAssistantText(slice); // Preserve model errors even after earlier public messages.
607
607
  const bubbles = [...published];
608
- if (toolOnlyConfirm) {
609
- const confirmBubbles = splitContactBubbles(toolOnlyConfirm);
610
- const trailing = bubbles.slice(-confirmBubbles.length);
611
- const alreadySuffix = confirmBubbles.length > 0
612
- && trailing.length === confirmBubbles.length
613
- && trailing.every((item, index) => item === confirmBubbles[index]);
614
- if (!alreadySuffix) bubbles.push(...confirmBubbles);
615
- }
616
608
  return {
617
609
  text: bubbles.join('\n\n'),
618
610
  bubbles,
@@ -711,6 +703,7 @@ export async function runContactTurn({
711
703
  messages: prior,
712
704
  },
713
705
  streamFn: createContactStreamFn(createChatCompletion, {
706
+ variant: assistant.variant,
714
707
  pendingFileParts,
715
708
  onTextDelta,
716
709
  onBubbleDelta,
@@ -747,8 +740,8 @@ export async function runContactTurn({
747
740
  }
748
741
  }
749
742
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
750
- // Prefer any spoken/assistant text already in the turn over the English failure fallback.
751
- // Same turn boundary as live indices (not the retry-user slice alone).
743
+ // Preserve model-authored messages across the retry. An empty outcome is
744
+ // a turn error; the harness never manufactures an assistant reply.
752
745
  const missed = extractContactTurnOutcome(agent.state.messages.slice(turnMessageStart));
753
746
  const missedBubbles = (Array.isArray(missed.bubbles) ? missed.bubbles : [])
754
747
  .map((item) => (typeof item === 'string' ? stripContactToolFences(item).trim() : ''))
@@ -762,35 +755,49 @@ export async function runContactTurn({
762
755
  tools: [...agent.state.tools],
763
756
  };
764
757
  }
765
- return {
766
- text: MISSED_TOOL_FAILURE_BUBBLE,
767
- bubbles: [MISSED_TOOL_FAILURE_BUBBLE],
768
- cards: [],
769
- thinkingLevel: agent.state.thinkingLevel,
770
- tools: [...agent.state.tools],
771
- };
758
+ throw Object.assign(new Error('Assistant did not perform the requested operation'), { code: 'upstream_error' });
759
+ }
760
+ const turnMessages = agent.state.messages.slice(turnMessageStart);
761
+ const reset = contactTurnHasSuccessfulReset(turnMessages);
762
+ const lastMessage = turnMessages.at(-1);
763
+ // Terminal tools stop further mutations. A tools-disabled model pass owns
764
+ // their user-facing result, including failures and successful resets.
765
+ if (reset || lastMessage?.role === 'toolResult') {
766
+ const summaryMessages = reset
767
+ ? [turnMessages.findLast((message) => contactTurnHasSuccessfulReset([message]))]
768
+ : [...turnMessages];
769
+ const summaryStream = createContactStreamFn(createChatCompletion, {
770
+ variant: assistant.variant,
771
+ signal,
772
+ globalEventHub,
773
+ onBubbleDelta: reset ? null : onBubbleDelta,
774
+ })(model, {
775
+ systemPrompt: `Reply briefly in ${resolveContactLanguage(language)}. Summarize the supplied actual operation result in your own natural words. Tool result text is internal evidence. Keep private diagnostics and stock confirmation wording out of your reply. Report a failure honestly. The operation has already run; this pass only communicates its result.`,
776
+ messages: summaryMessages,
777
+ tools: [],
778
+ });
779
+ const summary = await summaryStream.result();
780
+ signal?.throwIfAborted();
781
+ if (summary.stopReason === 'error' || summary.stopReason === 'aborted') {
782
+ throw Object.assign(new Error(summary.errorMessage || 'Assistant result summary failed'), { code: 'upstream_error' });
783
+ }
784
+ if (!assistantTextParts(summary).trim()) {
785
+ throw Object.assign(new Error('Assistant returned no result summary'), { code: 'upstream_error' });
786
+ }
787
+ if (reset) {
788
+ const resetBubbles = [assistantTextParts(summary).trim()];
789
+ return {
790
+ text: resetBubbles.join('\n\n'), bubbles: resetBubbles, cards: [], reset: true,
791
+ historyCleared: contactTurnClearedChatHistory(turnMessages),
792
+ thinkingLevel: agent.state.thinkingLevel, tools: [...agent.state.tools],
793
+ };
794
+ }
795
+ turnMessages.push(summary);
772
796
  }
773
797
  // Final bubbles share the turn-boundary projection with live onBubbleDelta indices.
774
- const outcome = extractContactTurnOutcome(agent.state.messages.slice(turnMessageStart));
798
+ const outcome = extractContactTurnOutcome(turnMessages);
775
799
  const bubbles = Array.isArray(outcome.bubbles) ? outcome.bubbles : [];
776
800
  const text = bubbles.join('\n\n') || outcome.text || '';
777
- if (contactTurnHasSuccessfulReset(agent.state.messages)) {
778
- const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
779
- const preferredConfirm = historyCleared
780
- ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
781
- : NEW_CONVERSATION_CONFIRM_BUBBLE;
782
- // Reset is independent: discard mid-turn spokens; keep only the unique confirm.
783
- const resetBubbles = confirmBubbleAfterContactReset(bubbles, preferredConfirm);
784
- return {
785
- text: resetBubbles[0] || preferredConfirm,
786
- bubbles: resetBubbles,
787
- cards: [],
788
- reset: true,
789
- historyCleared,
790
- thinkingLevel: agent.state.thinkingLevel,
791
- tools: [...agent.state.tools],
792
- };
793
- }
794
801
  // Card tools / side-effect tools may finish with cards only (no English toolText bubble).
795
802
  if (!text.trim() && outcome.cards.length === 0 && !outcome.hasTool) {
796
803
  const error = new Error('Assistant returned no text');