@openchambery/web 1.19.14 → 1.19.15-beta.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 (58) hide show
  1. package/dist/assets/{AssistantView-Dncbu_Z2.js → AssistantView-D5rEZ-Ss.js} +2 -2
  2. package/dist/assets/{DiagramView-CovEl1BY.js → DiagramView-DaB9Sb7S.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CCfHY-86.js → MarkdownRendererImpl-BWCAPlqn.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-y6QgIUZA.js → MarkstreamRendererImpl-BD_NFKUv.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  7. package/dist/assets/{MobileShareBridge-B4S7BaUj.js → MobileShareBridge-Bk2fdkJk.js} +1 -1
  8. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  9. package/dist/assets/{MultiRunWindow-BeHa-CG6.js → MultiRunWindow-rs49_K1E.js} +1 -1
  10. package/dist/assets/{SettingsView-Dn7odtQz.js → SettingsView-b8LUjoad.js} +2 -2
  11. package/dist/assets/{SettingsWindow-C0tWPS5f.js → SettingsWindow-D8JFlPdA.js} +1 -1
  12. package/dist/assets/{TerminalView-BnJgC7Qo.js → TerminalView-DCd-Godi.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-D9S-X8hM.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DBrvaJnq.js → appThemeRegistry-BWtCaTMe.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-Dc004xOh.js → fileMentionTouchSelection-DKYkoypI.js} +1 -1
  16. package/dist/assets/{gitApi-Cm3JLQqh.js → gitApi-D4PyYgLZ.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-CTWYFsbH.js → insertionBoundaries-Bnnx03Wj.js} +1 -1
  18. package/dist/assets/{main-CSqu3MB4.js → main-BRiQTNFX.js} +4 -4
  19. package/dist/assets/{main-DwMprEyr.js → main-CnY0LWNc.js} +2 -2
  20. package/dist/assets/{miniChat-C8bzY_Ye.js → miniChat-B0pw_mUs.js} +2 -2
  21. package/dist/assets/{mobile-Oy-SRKkr.js → mobile-BKHmxwXz.js} +2 -2
  22. package/dist/assets/{multirun-ClcuaiKU.js → multirun-BQKqVfFT.js} +1 -1
  23. package/dist/assets/{projectMeta-F93wE2Ye.js → projectMeta-BvG8sdtC.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-rOOhMVlQ.js → renderElectronMiniChatApp-E5a0yy7E.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BP_hGwHP.js → renderMobileApp-DT0L4Ah6.js} +4 -4
  26. package/dist/assets/{runtimeConfig-iJ2mTBVN.js → runtimeConfig-utWzrDwa.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-ISnhAS3q.js → runtimeEndpointReset-DE6lwHyg.js} +1 -1
  28. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  29. package/dist/assets/{sessionLookup-DQQ9-DuP.js → sessionLookup-12zVOR0J.js} +1 -1
  30. package/dist/assets/src-CFUi4riW.css +2 -0
  31. package/dist/assets/{useAppFontEffects--IcU7jyv.js → useAppFontEffects-BhYuP4L2.js} +5 -5
  32. package/dist/assets/{useDesktopWindowControlsLayout-BqJ5R_T2.js → useDesktopWindowControlsLayout-DPNMRkrI.js} +1 -1
  33. package/dist/assets/{useEffectiveDirectory-ChWXV8xM.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  34. package/dist/assets/{useMobileNavigationStore-DQkgxuzg.js → useMobileNavigationStore-BYdpuwL7.js} +1 -1
  35. package/dist/assets/{useSessionAutoCleanup-DWWAvQQ0.js → useSessionAutoCleanup-CADZCvZL.js} +1 -1
  36. package/dist/assets/{useWorkerHighlightedLines-D2oQszsk.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
  37. package/dist/index.html +3 -3
  38. package/dist/mini-chat.html +3 -3
  39. package/dist/mobile.html +3 -3
  40. package/package.json +1 -1
  41. package/server/lib/assistants/DOCUMENTATION.md +33 -12
  42. package/server/lib/assistants/contact-store.js +25 -1
  43. package/server/lib/assistants/contact-store.test.js +39 -5
  44. package/server/lib/assistants/contact-tools.js +3 -3
  45. package/server/lib/assistants/harness.js +136 -101
  46. package/server/lib/assistants/harness.test.js +235 -8
  47. package/server/lib/assistants/pi-tools.js +2 -0
  48. package/server/lib/assistants/pi-tools.test.js +8 -0
  49. package/server/lib/assistants/service.js +313 -74
  50. package/server/lib/assistants/service.test.js +555 -18
  51. package/server/lib/llm/DOCUMENTATION.md +9 -4
  52. package/server/lib/llm/generate.js +45 -6
  53. package/server/lib/llm/generate.test.js +52 -0
  54. package/dist/assets/MarkdownRenderer-CZqok-Cf.js +0 -2
  55. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +0 -1
  56. package/dist/assets/MobileSurface-xL5Jby4U.js +0 -2
  57. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +0 -2
  58. package/dist/assets/src-DYeppLfP.css +0 -2
@@ -115,7 +115,9 @@ export const resolveContactLanguage = (value) => {
115
115
  };
116
116
 
117
117
  export const CONTACT_SYSTEM_PROMPT = [
118
- "You are OpenChamber's in-app assistant — a personable contact who can also work in the user's configured project directory.",
118
+ "You are OpenChamber's in-app assistant — a personable contact focused on understanding the user, communicating naturally, coordinating work, and following up on results.",
119
+ 'For substantive tasks, default to finding the relevant existing OpenCode workspace and handing the task to a worker session with assign_session. Carry the user goal, relevant context, constraints, and verification expectations into that session; follow its results and explain the outcome in normal conversation.',
120
+ 'Handle simple questions, file lookups, and small, clearly scoped configuration changes directly when their scope and verification are straightforward. As a task grows into implementation, multi-step debugging, or broad changes, hand it to a worker session. An explicit user request to handle a bounded task yourself can use the direct tools.',
119
121
  'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
120
122
  'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
121
123
  '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.',
@@ -123,17 +125,18 @@ export const CONTACT_SYSTEM_PROMPT = [
123
125
  'Do not expose tool traces, Activity, or editor actions.',
124
126
  '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.',
125
127
  '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.',
126
- 'You receive the registered project catalog every turn. You CAN see those projects. Look them up yourself (fuzzy match label/name/path). Never say you cannot see the registered project list. Never ask for a raw filesystem path when a name matches. If the catalog is empty, tell the user to add a project in Settings.',
127
- 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID. To continue an existing chat, assign_session with sessionID plus a coding prompt (omit model args to keep the prior worker model). To only listen without prompting when asked (监听/watch/monitor), watch_session with sessionID — a plain @session:id mention alone is not a watch request. To abort a running session, stop_session with sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session or watch_session ends the turn do not call it again.',
128
+ 'You receive the registered project catalog every turn. Match the user goal against project labels and paths and existing OpenCode conversations via list_sessions. Start in the user-configured working directory; managed contacts start in the current server user home directory and can inspect user-global directories with read/bash. When the starting directory has no relevant session, walk its parent directories to find the nearest relevant existing OpenCode workspace using session directory metadata, then search that workspace for the requested project or conversation. Use an unscoped list_sessions lookup when broader context is needed. Prefer the user-configured directory and explicit session context; ask a short question when several relevant targets remain. Keep discovery bounded and use actual tool results as evidence.',
129
+ 'An empty registered catalog still permits looking up existing sessions and inspecting the working directory. Resolve the existing workspace first. Assignment requires a registered project: when the resolved workspace needs registration, explain that specific prerequisite and ask the user to register it in Settings. Workspace discovery should preserve the user existing directory structure.',
130
+ 'For substantive work, match the workspace, inspect relevant existing chats with list_sessions, then create a worker through assign_session with projectPath or continue the relevant conversation with sessionID plus the task prompt (omit model args to keep the prior worker model). File and shell tools support discovery and the small-task exception. To only listen without prompting when asked (监听/watch/monitor), watch_session with sessionID — a plain @session:id mention alone is not a watch request. To abort a running session, stop_session with sessionID. Optional worker model via providerID/modelID/model from the connected catalog — that does not change this contact. Current-turn user attachments are server-forwarded on assign. One successful assign_session or watch_session ends the turn — do not call it again.',
128
131
  'A reply without the tool call does nothing. Never say 已创建, created, scheduled, opened, watched, or stopped unless the tool already returned success.',
129
132
  ].join(' ');
130
133
 
131
134
  // A workspace response must explicitly finish or ask for missing input. Plain
132
135
  // progress prose is not a final answer and must not silently end the pi loop.
133
136
  const WORKSPACE_RESPONSE_PROTOCOL = [
134
- 'Workspace response protocol: continue using openchamber-tool calls until the requested work and verification are done.',
137
+ 'Workspace response protocol: continue using openchamber-tool calls until the task is handed to a worker session, or the small direct task and its verification are done. A successful assign_session ends this turn and worker completion is reported through the session follow-up.',
135
138
  'You may batch independent coding calls, but they execute sequentially in the order supplied. Wait for results before using their output. Send OpenChamber operation tools one at a time.',
136
- 'Reading a skill only loads instructions; execute its applicable steps before completing the task.',
139
+ 'Reading a skill loads instructions. For substantive work, pass relevant instructions and context to the worker session; for a small direct task, execute the applicable steps and verify the result before completing.',
137
140
  'To finish, reply with only an openchamber-final JSON fence: ```openchamber-final\n{"status":"complete","text":"Your final answer in the user language"}\n```.',
138
141
  'Use status "blocked" with the missing information or real blocker when you cannot proceed. Questions and ordinary conversation also use this final format; they do not require a tool call.',
139
142
  'Do not use a final declaration for a progress update or promise to act later. Never invent tool results, User messages, or future assistant turns. Only supplied tool-result records are execution evidence.',
@@ -207,14 +210,69 @@ const completionFileParts = (value) => (Array.isArray(value) ? value : [])
207
210
  /** Fence start (openchamber-tool or generic ```) — stop live bubble deltas. */
208
211
  const CONTACT_FENCE_START = /```/;
209
212
 
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
+ ]);
234
+
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
+ ]);
239
+
240
+ const assistantTextParts = (message) => (Array.isArray(message?.content) ? message.content : [])
241
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
242
+ .map((part) => part.text)
243
+ .join('');
244
+
245
+ /**
246
+ * Streamed assistant bubbles already present in this turn's Agent messages.
247
+ * Spoken preambles (tool turns) and final assistant text share splitContactBubbles.
248
+ * Independent messages keep duplicate text. Tool-result confirms are not streamed here.
249
+ */
250
+ export function projectStreamedContactTurnBubbles(messages, turnStart = 0) {
251
+ const start = Number.isFinite(Number(turnStart)) ? Math.max(0, Number(turnStart)) : 0;
252
+ const list = Array.isArray(messages) ? messages.slice(start) : [];
253
+ const bubbles = [];
254
+ for (const message of list) {
255
+ if (message?.role !== 'assistant') continue;
256
+ const parts = Array.isArray(message.content) ? message.content : [];
257
+ const hasTool = parts.some((part) => part?.type === 'toolCall');
258
+ const text = assistantTextParts(message).trim();
259
+ if (!text) continue;
260
+ if (hasTool && !isContactSpokenPreamble(text)) continue;
261
+ for (const bubble of splitContactBubbles(text)) bubbles.push(bubble);
262
+ }
263
+ return bubbles;
264
+ }
265
+
210
266
  /**
211
267
  * Incremental bubble stream from live token deltas.
212
268
  * - done:false = token increment for the active bubble
213
269
  * - done:true = that bubble is complete (split off or finalized)
270
+ * - baseIndex offsets indices so later completions continue the turn sequence
214
271
  * Stops when a markdown fence starts so tool JSON never leaks.
215
272
  */
216
- export function createContactBubbleDeltaTracker(onBubbleDelta) {
273
+ export function createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex = 0 } = {}) {
217
274
  const emit = typeof onBubbleDelta === 'function' ? onBubbleDelta : null;
275
+ const offset = Number.isFinite(Number(baseIndex)) ? Math.max(0, Number(baseIndex)) : 0;
218
276
  let accumulated = '';
219
277
  let completedCount = 0;
220
278
  let activeEmitted = '';
@@ -224,7 +282,7 @@ export function createContactBubbleDeltaTracker(onBubbleDelta) {
224
282
  const push = (index, delta, done) => {
225
283
  if (!emit || (typeof delta === 'string' && delta.length === 0 && !done)) return;
226
284
  emittedAny = true;
227
- emit(index, delta, done);
285
+ emit(offset + index, delta, done);
228
286
  };
229
287
 
230
288
  const applyVisible = (visible) => {
@@ -251,6 +309,9 @@ export function createContactBubbleDeltaTracker(onBubbleDelta) {
251
309
  get stopped() {
252
310
  return stopped;
253
311
  },
312
+ get baseIndex() {
313
+ return offset;
314
+ },
254
315
  pushDelta(delta) {
255
316
  if (stopped || typeof delta !== 'string' || !delta) return;
256
317
  const next = accumulated + delta;
@@ -297,8 +358,11 @@ export function createContactStreamFn(createChatCompletion, {
297
358
  globalEventHub = null,
298
359
  bubbleGapMs = 0,
299
360
  signal = null,
361
+ /** Fixed Agent message length before this contact turn (prior history only). */
362
+ turnMessageStart = 0,
300
363
  } = {}) {
301
364
  let callSequence = 0;
365
+ const turnStart = Number.isFinite(Number(turnMessageStart)) ? Math.max(0, Number(turnMessageStart)) : 0;
302
366
  return (model, context) => {
303
367
  const stream = createAssistantMessageEventStream();
304
368
  const run = async () => {
@@ -403,7 +467,9 @@ export function createContactStreamFn(createChatCompletion, {
403
467
  { 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.'}` },
404
468
  );
405
469
  }
406
- const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
470
+ // Continue turn-global bubble indices from already-streamed turn messages.
471
+ const baseIndex = projectStreamedContactTurnBubbles(context.messages, turnStart).length;
472
+ const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex });
407
473
  if (parsed.toolCalls.length > 0) {
408
474
  const toolCalls = parsed.toolCalls.map((call) => ({
409
475
  type: 'toolCall',
@@ -412,6 +478,7 @@ export function createContactStreamFn(createChatCompletion, {
412
478
  arguments: call.arguments,
413
479
  }));
414
480
  const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
481
+ const spokenBubbles = spoken ? splitContactBubbles(spoken) : [];
415
482
  const content = spoken
416
483
  ? [{ type: 'text', text: spoken }, ...toolCalls]
417
484
  : toolCalls;
@@ -419,7 +486,7 @@ export function createContactStreamFn(createChatCompletion, {
419
486
  ...assistantMessage(model, spoken, 'toolUse'),
420
487
  content,
421
488
  };
422
- if (spoken) bubbleTracker.finish([spoken]);
489
+ if (spokenBubbles.length > 0) bubbleTracker.finish(spokenBubbles);
423
490
  stream.push({ type: 'start', partial });
424
491
  if (spoken) {
425
492
  stream.push({ type: 'text_start', contentIndex: 0, partial });
@@ -440,7 +507,7 @@ export function createContactStreamFn(createChatCompletion, {
440
507
  const replyBubbles = splitContactBubbles(chatText);
441
508
  if (bubbleGapMs > 0 && replyBubbles.length > 1) {
442
509
  for (let index = 0; index < replyBubbles.length; index += 1) {
443
- onBubbleDelta?.(index, replyBubbles[index], true);
510
+ onBubbleDelta?.(baseIndex + index, replyBubbles[index], true);
444
511
  if (index < replyBubbles.length - 1) {
445
512
  await new Promise((resolve) => setTimeout(resolve, bubbleGapMs));
446
513
  }
@@ -471,22 +538,6 @@ const userMessageText = (message) => {
471
538
  return (message?.content || []).map((part) => (typeof part?.text === 'string' ? part.text : '')).join('');
472
539
  };
473
540
 
474
- const assistantTextParts = (message) => (Array.isArray(message?.content) ? message.content : [])
475
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
476
- .map((part) => part.text)
477
- .join('');
478
-
479
- const extractSpokenPreamble = (messages) => {
480
- for (const message of Array.isArray(messages) ? messages : []) {
481
- if (message?.role !== 'assistant') continue;
482
- const parts = Array.isArray(message.content) ? message.content : [];
483
- if (!parts.some((part) => part?.type === 'toolCall')) continue;
484
- const text = assistantTextParts(message).trim();
485
- if (isContactSpokenPreamble(text)) return text;
486
- }
487
- return '';
488
- };
489
-
490
541
  const extractAssistantText = (messages) => {
491
542
  for (let index = messages.length - 1; index >= 0; index -= 1) {
492
543
  const message = messages[index];
@@ -518,79 +569,56 @@ const extractToolResultText = (messages) => {
518
569
  return '';
519
570
  };
520
571
 
521
- /** Tool confirms that are themselves the user-facing bubble (no card). */
522
- const TOOL_TEXT_BUBBLE_TOOLS = new Set([
523
- STOP_SESSION_TOOL_NAME,
524
- STEER_SESSION_TOOL_NAME,
525
- ARCHIVE_SESSION_TOOL_NAME,
526
- DELETE_SESSION_TOOL_NAME,
527
- NEW_CONVERSATION_TOOL_NAME,
528
- CLEAR_CHAT_HISTORY_TOOL_NAME,
529
- UPDATE_DEFAULT_PROMPT_TOOL_NAME,
530
- GET_ASSISTANT_SETTINGS_TOOL_NAME,
531
- ]);
532
-
533
- /** Card / side-effect tools: never paint English toolText into the transcript. */
534
- const CARD_SIDE_EFFECT_TOOLS = new Set([
535
- ASSIGN_SESSION_TOOL_NAME,
536
- WATCH_SESSION_TOOL_NAME,
537
- STOP_SESSION_TOOL_NAME,
538
- CREATE_ASSISTANT_TOOL_NAME,
539
- SCHEDULE_TASK_TOOL_NAME,
540
- MESSAGE_ASSISTANT_TOOL_NAME,
541
- ]);
542
-
543
- /** Lookup payloads for the model only — never paint quoted JSON as a user bubble. */
544
- const MODEL_ONLY_LOOKUP_TOOLS = new Set([
545
- READ_SESSION_TOOL_NAME,
546
- ]);
547
-
548
- const extractContactTurnOutcome = (messages, retried) => {
549
- const list = Array.isArray(messages) ? messages : [];
550
- let start = 0;
551
- if (retried) {
552
- for (let index = list.length - 1; index >= 0; index -= 1) {
553
- if (list[index]?.role === 'user') {
554
- start = index;
555
- break;
556
- }
557
- }
558
- if (start === 0) {
559
- const toolIndex = list.findIndex((message) => message?.role === 'toolResult');
560
- if (toolIndex >= 0) start = toolIndex;
561
- }
562
- }
563
- const slice = list.slice(start);
572
+ /**
573
+ * Final turn bubbles reuse the same projection as live onBubbleDelta indices
574
+ * (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.
577
+ */
578
+ const extractContactTurnOutcome = (messages) => {
579
+ const slice = Array.isArray(messages) ? messages : [];
564
580
  const cards = extractContactCardsFromMessages(slice);
565
581
  const hasTool = contactTurnHasToolResult(slice);
566
- const spoken = extractSpokenPreamble(slice);
582
+ // Same rules as live SSE indices — keep every independent spoken/final bubble.
583
+ const published = projectStreamedContactTurnBubbles(slice, 0);
567
584
  const lastToolName = [...slice].reverse().find((message) => message?.role === 'toolResult')?.toolName;
568
585
  const coding = isPiCodingToolName(lastToolName);
569
586
  const toolText = extractToolResultText(slice);
570
587
  const assistant = extractAssistantText(slice);
571
- let confirm = '';
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 = '';
572
593
  if (hasTool && !coding) {
573
594
  if (TOOL_TEXT_BUBBLE_TOOLS.has(lastToolName)) {
574
- // Confirm-only tools: toolText is the user bubble.
575
- confirm = toolText || assistant;
576
- } else if (CARD_SIDE_EFFECT_TOOLS.has(lastToolName)) {
577
- // Spoken preamble only. Card is enough when present; never English toolText.
578
- // Post-tool assistant text (terminate:false tools) may still surface when no spoken.
579
- confirm = spoken ? '' : (assistant || '');
580
- } else if (MODEL_ONLY_LOOKUP_TOOLS.has(lastToolName)) {
581
- // Quoted transcript stays in the tool result. Keep spoken + post-read reply.
582
- confirm = assistant || '';
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 = '';
583
599
  } else {
584
600
  // list_projects / list_sessions and other app tools keep toolText.
585
- confirm = toolText || assistant;
601
+ toolOnlyConfirm = toolText || '';
602
+ if (!toolOnlyConfirm && published.length === 0) toolOnlyConfirm = assistant || '';
586
603
  }
587
- } else {
588
- confirm = assistant || (coding ? toolText : '');
604
+ } else if (hasTool && coding && published.length === 0) {
605
+ toolOnlyConfirm = toolText || '';
606
+ }
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);
589
615
  }
590
- const parts = [];
591
- if (spoken) parts.push(spoken);
592
- if (confirm && confirm !== spoken) parts.push(confirm);
593
- return { text: parts.join('\n\n'), cards, hasTool };
616
+ return {
617
+ text: bubbles.join('\n\n'),
618
+ bubbles,
619
+ cards,
620
+ hasTool,
621
+ };
594
622
  };
595
623
 
596
624
  /**
@@ -670,6 +698,8 @@ export async function runContactTurn({
670
698
  }
671
699
  ))
672
700
  : [];
701
+ // Fixed turn boundary: every completion and missed-tool retry continues bubble indices from here.
702
+ const turnMessageStart = prior.length;
673
703
 
674
704
  const agent = new AgentImpl({
675
705
  toolExecution: 'sequential',
@@ -687,6 +717,7 @@ export async function runContactTurn({
687
717
  globalEventHub,
688
718
  bubbleGapMs: 280,
689
719
  signal,
720
+ turnMessageStart,
690
721
  }),
691
722
  });
692
723
 
@@ -705,9 +736,7 @@ export async function runContactTurn({
705
736
  const hasRequestedResult = () => agent.state.messages.some((message) => (
706
737
  message?.role === 'toolResult' && requested.includes(message.toolName)
707
738
  ));
708
- let retried = false;
709
739
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
710
- retried = true;
711
740
  signal?.throwIfAborted();
712
741
  await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
713
742
  signal?.throwIfAborted();
@@ -719,13 +748,15 @@ export async function runContactTurn({
719
748
  }
720
749
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
721
750
  // Prefer any spoken/assistant text already in the turn over the English failure fallback.
722
- const missed = extractContactTurnOutcome(agent.state.messages, retried);
723
- const missedText = stripContactToolFences(missed.text).trim();
724
- if (missedText) {
725
- const missedBubbles = splitContactBubbles(missedText);
751
+ // Same turn boundary as live indices (not the retry-user slice alone).
752
+ const missed = extractContactTurnOutcome(agent.state.messages.slice(turnMessageStart));
753
+ const missedBubbles = (Array.isArray(missed.bubbles) ? missed.bubbles : [])
754
+ .map((item) => (typeof item === 'string' ? stripContactToolFences(item).trim() : ''))
755
+ .filter(Boolean);
756
+ if (missedBubbles.length > 0) {
726
757
  return {
727
- text: missedText,
728
- bubbles: missedBubbles.length > 0 ? missedBubbles : [missedText],
758
+ text: missedBubbles.join('\n\n'),
759
+ bubbles: missedBubbles,
729
760
  cards: [],
730
761
  thinkingLevel: agent.state.thinkingLevel,
731
762
  tools: [...agent.state.tools],
@@ -739,17 +770,20 @@ export async function runContactTurn({
739
770
  tools: [...agent.state.tools],
740
771
  };
741
772
  }
742
- const outcome = extractContactTurnOutcome(agent.state.messages, retried);
743
- const text = outcome.text;
773
+ // Final bubbles share the turn-boundary projection with live onBubbleDelta indices.
774
+ const outcome = extractContactTurnOutcome(agent.state.messages.slice(turnMessageStart));
775
+ const bubbles = Array.isArray(outcome.bubbles) ? outcome.bubbles : [];
776
+ const text = bubbles.join('\n\n') || outcome.text || '';
744
777
  if (contactTurnHasSuccessfulReset(agent.state.messages)) {
745
778
  const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
746
779
  const preferredConfirm = historyCleared
747
780
  ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
748
781
  : NEW_CONVERSATION_CONFIRM_BUBBLE;
749
- const bubbles = confirmBubbleAfterContactReset(splitContactBubbles(text), preferredConfirm);
782
+ // Reset is independent: discard mid-turn spokens; keep only the unique confirm.
783
+ const resetBubbles = confirmBubbleAfterContactReset(bubbles, preferredConfirm);
750
784
  return {
751
- text: bubbles[0] || preferredConfirm,
752
- bubbles,
785
+ text: resetBubbles[0] || preferredConfirm,
786
+ bubbles: resetBubbles,
753
787
  cards: [],
754
788
  reset: true,
755
789
  historyCleared,
@@ -763,9 +797,10 @@ export async function runContactTurn({
763
797
  error.code = 'upstream_error';
764
798
  throw error;
765
799
  }
800
+ // Published stream indices are a stable prefix of the final array on normal turns.
766
801
  return {
767
802
  text,
768
- bubbles: splitContactBubbles(text),
803
+ bubbles,
769
804
  cards: outcome.cards,
770
805
  thinkingLevel: agent.state.thinkingLevel,
771
806
  tools: [...agent.state.tools],