@openchambery/web 1.19.13 → 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 (120) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  10. package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
  11. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-b8LUjoad.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-DKYkoypI.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-D4PyYgLZ.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-Bnnx03Wj.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-BRiQTNFX.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-B0pw_mUs.js +2 -0
  41. package/dist/assets/mobile-BKHmxwXz.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-BvG8sdtC.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
  49. package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  53. package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-CFUi4riW.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects-BhYuP4L2.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +120 -22
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +520 -11
  83. package/server/lib/assistants/contact-store.test.js +362 -12
  84. package/server/lib/assistants/contact-tools.js +305 -54
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +295 -132
  88. package/server/lib/assistants/harness.test.js +662 -10
  89. package/server/lib/assistants/pi-tools.js +2 -0
  90. package/server/lib/assistants/pi-tools.test.js +8 -0
  91. package/server/lib/assistants/routes.js +3 -0
  92. package/server/lib/assistants/service.js +861 -79
  93. package/server/lib/assistants/service.test.js +1234 -8
  94. package/server/lib/llm/DOCUMENTATION.md +10 -3
  95. package/server/lib/llm/completions.js +4 -0
  96. package/server/lib/llm/completions.test.js +3 -0
  97. package/server/lib/llm/generate.js +57 -11
  98. package/server/lib/llm/generate.test.js +92 -0
  99. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  100. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  101. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  102. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
  103. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  104. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  105. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  106. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  107. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  108. package/dist/assets/main-BjeOhdcG.js +0 -169
  109. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  110. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  111. package/dist/assets/platform-DzsGqlGW.js +0 -2
  112. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  113. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  115. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  116. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  117. package/dist/assets/src-AA-5nXsF.css +0 -2
  118. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  119. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  120. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -21,9 +21,15 @@ import {
21
21
  NEW_CONVERSATION_CONFIRM_BUBBLE,
22
22
  NEW_CONVERSATION_TOOL_NAME,
23
23
  parseContactToolCalls,
24
+ READ_SESSION_TOOL_NAME,
24
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,
25
30
  stripContactToolFences,
26
31
  UPDATE_DEFAULT_PROMPT_TOOL_NAME,
32
+ WATCH_SESSION_TOOL_NAME,
27
33
  } from './contact-tools.js';
28
34
  import {
29
35
  createPiCodingRuntime,
@@ -79,19 +85,75 @@ function createAssistantMessageEventStream() {
79
85
  };
80
86
  }
81
87
 
88
+ const CONTACT_LANGUAGE_NAMES = {
89
+ en: 'English',
90
+ fr: 'French',
91
+ 'zh-CN': 'Simplified Chinese',
92
+ 'zh-TW': 'Traditional Chinese',
93
+ uk: 'Ukrainian',
94
+ es: 'Spanish',
95
+ 'pt-BR': 'Brazilian Portuguese',
96
+ ko: 'Korean',
97
+ pl: 'Polish',
98
+ ja: 'Japanese',
99
+ };
100
+
101
+ const CONTACT_LANGUAGE_FALLBACK = "the user's interface language";
102
+
103
+ /**
104
+ * Map a UI locale code to a display name for the system prompt. Unknown or
105
+ * missing values fall back to a neutral phrase so `{{LANGUAGE}}` never leaks.
106
+ * Whitelisted only: arbitrary client text must not reach the system prompt.
107
+ */
108
+ export const resolveContactLanguage = (value) => {
109
+ if (typeof value !== 'string') return CONTACT_LANGUAGE_FALLBACK;
110
+ const code = value.trim();
111
+ if (!code) return CONTACT_LANGUAGE_FALLBACK;
112
+ if (CONTACT_LANGUAGE_NAMES[code]) return CONTACT_LANGUAGE_NAMES[code];
113
+ const match = Object.keys(CONTACT_LANGUAGE_NAMES).find((key) => key.toLowerCase() === code.toLowerCase());
114
+ return match ? CONTACT_LANGUAGE_NAMES[match] : CONTACT_LANGUAGE_FALLBACK;
115
+ };
116
+
82
117
  export const CONTACT_SYSTEM_PROMPT = [
83
- "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.',
84
121
  'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
85
122
  'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
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.',
86
124
  'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
87
125
  'Do not expose tool traces, Activity, or editor actions.',
88
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.',
89
- '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 schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
90
- '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.',
91
- '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. 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 ends the turn do not call it again.',
92
- 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
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.',
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.',
131
+ 'A reply without the tool call does nothing. Never say 已创建, created, scheduled, opened, watched, or stopped unless the tool already returned success.',
93
132
  ].join(' ');
94
133
 
134
+ // A workspace response must explicitly finish or ask for missing input. Plain
135
+ // progress prose is not a final answer and must not silently end the pi loop.
136
+ const WORKSPACE_RESPONSE_PROTOCOL = [
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.',
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.',
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.',
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```.',
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.',
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.',
143
+ ].join('\n');
144
+
145
+ const parseFinalResponse = (text) => {
146
+ const match = text.trim().match(/^```openchamber-final\s*([\s\S]*?)```$/u);
147
+ if (!match) return null;
148
+ try {
149
+ const value = JSON.parse(match[1]);
150
+ if (!value || !['complete', 'blocked'].includes(value.status) || typeof value.text !== 'string' || !value.text.trim()) return null;
151
+ return { status: value.status, text: value.text.trim() };
152
+ } catch {
153
+ return null;
154
+ }
155
+ };
156
+
95
157
  const emptyUsage = () => ({
96
158
  input: 0,
97
159
  output: 0,
@@ -148,14 +210,69 @@ const completionFileParts = (value) => (Array.isArray(value) ? value : [])
148
210
  /** Fence start (openchamber-tool or generic ```) — stop live bubble deltas. */
149
211
  const CONTACT_FENCE_START = /```/;
150
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
+
151
266
  /**
152
267
  * Incremental bubble stream from live token deltas.
153
268
  * - done:false = token increment for the active bubble
154
269
  * - done:true = that bubble is complete (split off or finalized)
270
+ * - baseIndex offsets indices so later completions continue the turn sequence
155
271
  * Stops when a markdown fence starts so tool JSON never leaks.
156
272
  */
157
- export function createContactBubbleDeltaTracker(onBubbleDelta) {
273
+ export function createContactBubbleDeltaTracker(onBubbleDelta, { baseIndex = 0 } = {}) {
158
274
  const emit = typeof onBubbleDelta === 'function' ? onBubbleDelta : null;
275
+ const offset = Number.isFinite(Number(baseIndex)) ? Math.max(0, Number(baseIndex)) : 0;
159
276
  let accumulated = '';
160
277
  let completedCount = 0;
161
278
  let activeEmitted = '';
@@ -165,7 +282,7 @@ export function createContactBubbleDeltaTracker(onBubbleDelta) {
165
282
  const push = (index, delta, done) => {
166
283
  if (!emit || (typeof delta === 'string' && delta.length === 0 && !done)) return;
167
284
  emittedAny = true;
168
- emit(index, delta, done);
285
+ emit(offset + index, delta, done);
169
286
  };
170
287
 
171
288
  const applyVisible = (visible) => {
@@ -192,6 +309,9 @@ export function createContactBubbleDeltaTracker(onBubbleDelta) {
192
309
  get stopped() {
193
310
  return stopped;
194
311
  },
312
+ get baseIndex() {
313
+ return offset;
314
+ },
195
315
  pushDelta(delta) {
196
316
  if (stopped || typeof delta !== 'string' || !delta) return;
197
317
  const next = accumulated + delta;
@@ -237,7 +357,12 @@ export function createContactStreamFn(createChatCompletion, {
237
357
  onBubbleDelta = null,
238
358
  globalEventHub = null,
239
359
  bubbleGapMs = 0,
360
+ signal = null,
361
+ /** Fixed Agent message length before this contact turn (prior history only). */
362
+ turnMessageStart = 0,
240
363
  } = {}) {
364
+ let callSequence = 0;
365
+ const turnStart = Number.isFinite(Number(turnMessageStart)) ? Math.max(0, Number(turnMessageStart)) : 0;
241
366
  return (model, context) => {
242
367
  const stream = createAssistantMessageEventStream();
243
368
  const run = async () => {
@@ -257,10 +382,13 @@ export function createContactStreamFn(createChatCompletion, {
257
382
  }];
258
383
  }
259
384
  if (message.role === 'assistant') {
260
- const content = (message.content || [])
261
- .filter((part) => part?.type === 'text')
262
- .map((part) => part.text)
263
- .join('');
385
+ const content = (message.content || []).map((part) => {
386
+ if (part?.type === 'text') return part.text;
387
+ if (part?.type !== 'toolCall') return '';
388
+ return '```openchamber-tool\n' + JSON.stringify({
389
+ id: part.id, name: part.name, arguments: part.arguments,
390
+ }) + '\n```';
391
+ }).filter(Boolean).join('\n\n');
264
392
  return content ? [{ role: 'assistant', content }] : [];
265
393
  }
266
394
  if (message.role === 'toolResult') {
@@ -268,7 +396,6 @@ export function createContactStreamFn(createChatCompletion, {
268
396
  .filter((part) => part?.type === 'text')
269
397
  .map((part) => part.text)
270
398
  .join('');
271
- if (!content) return [];
272
399
  const toolName = typeof message.toolName === 'string' && message.toolName.trim()
273
400
  ? message.toolName.trim()
274
401
  : 'result';
@@ -278,7 +405,7 @@ export function createContactStreamFn(createChatCompletion, {
278
405
  const label = callId
279
406
  ? `OpenChamber tool result name=${toolName} call=${callId}`
280
407
  : `OpenChamber tool result name=${toolName}`;
281
- return [{ role: 'user', content: `${label}: ${content}` }];
408
+ return [{ role: 'user', content: `${label}: ${JSON.stringify({ isError: message.isError === true, content })}` }];
282
409
  }
283
410
  return [];
284
411
  });
@@ -290,64 +417,97 @@ export function createContactStreamFn(createChatCompletion, {
290
417
  if (lastUser && fallbackFiles.length > 0 && !Array.isArray(lastUser.parts)) {
291
418
  lastUser.parts = fallbackFiles;
292
419
  }
293
- const bubbleTracker = createContactBubbleDeltaTracker(onBubbleDelta);
294
- const result = await createChatCompletion({
295
- body: {
296
- model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
297
- providerID: typeof model.name === 'string' && model.name.includes('/')
298
- ? model.name.split('/')[0]
299
- : undefined,
300
- modelID: model.id,
301
- messages,
302
- },
303
- onTextDelta: (delta) => {
304
- if (typeof delta !== 'string' || !delta) return;
305
- // Do not live-paint raw tokens: they are often chain-of-thought
306
- // and would appear then vanish after parse/persist.
307
- if (typeof onTextDelta === 'function') onTextDelta(delta);
308
- },
309
- globalEventHub,
310
- });
311
- const text = result?.completion?.choices?.[0]?.message?.content
312
- ?? result?.text
313
- ?? '';
314
420
  const allowedNames = (context.tools || []).map((tool) => tool?.name).filter(Boolean);
315
- const parsed = parseContactToolCalls(text, allowedNames);
316
- if (parsed.toolCall) {
317
- const toolCall = {
421
+ const requiresFinal = allowedNames.some(isPiCodingToolName);
422
+ let text;
423
+ let parsed;
424
+ let finalText;
425
+ let rejectedCodingCalls = [];
426
+ // Repair only the rejected response: no tools have executed from it.
427
+ // Real results stay in messages, so already completed work is not replayed.
428
+ for (let attempt = 0; ; attempt += 1) {
429
+ signal?.throwIfAborted();
430
+ const result = await createChatCompletion({
431
+ signal,
432
+ body: {
433
+ model: `${model.provider === 'openchamber' ? '' : `${model.provider}/`}${model.id}`.replace(/^\//, '') || model.name,
434
+ providerID: typeof model.name === 'string' && model.name.includes('/')
435
+ ? model.name.split('/')[0]
436
+ : undefined,
437
+ modelID: model.id,
438
+ messages: [...messages],
439
+ },
440
+ onTextDelta: (delta) => {
441
+ if (typeof delta === 'string' && delta && typeof onTextDelta === 'function') onTextDelta(delta);
442
+ },
443
+ globalEventHub,
444
+ });
445
+ signal?.throwIfAborted();
446
+ text = result?.completion?.choices?.[0]?.message?.content ?? result?.text ?? '';
447
+ const finalResponse = parseFinalResponse(text);
448
+ finalText = finalResponse?.text ?? null;
449
+ // A final answer is opaque text; quoted tool examples in it cannot run.
450
+ parsed = finalText === null ? parseContactToolCalls(text, allowedNames) : { toolCalls: [], chatText: finalText };
451
+ const mixedBatch = parsed.toolCalls.length > 1 && parsed.toolCalls.some((call) => !isPiCodingToolName(call.name));
452
+ const malformedFinal = finalText === null && /```openchamber-final/u.test(parsed.chatText);
453
+ const skippedRejectedWork = rejectedCodingCalls.length > 0 && finalResponse?.status === 'complete';
454
+ const forgedTranscript = finalText === null && /(?:^|\n)\s*(?:(?:User|Assistant|Tool):|OpenChamber tool result name=)/iu.test(parsed.chatText);
455
+ const protocolError = parsed.protocolError
456
+ || (forgedTranscript ? 'Do not simulate user messages, assistant turns, or tool results. Output only your next real tool call and wait for execution.' : '')
457
+ || (skippedRejectedWork ? 'Previously rejected coding calls have not executed. Issue the remaining tool calls, or report a blocked outcome; do not declare them complete.' : '')
458
+ || (mixedBatch ? 'Send OpenChamber operation tools one at a time, without other calls in the same response.' : '')
459
+ || (malformedFinal ? 'The final declaration must be a single valid openchamber-final JSON fence with status and text, without other content.' : '')
460
+ || (requiresFinal && parsed.toolCalls.length === 0 && finalText === null ? 'A workspace response needs an actual tool call or an explicit final declaration.' : '');
461
+ if (!protocolError) break;
462
+ if (attempt >= 2) throw new Error('Assistant response protocol failed after two corrections; the task was not confirmed complete.');
463
+ const rejected = parsed.toolCalls.filter((call) => isPiCodingToolName(call.name));
464
+ if (rejected.length > 0) rejectedCodingCalls = rejected.map(({ name, arguments: args }) => ({ name, arguments: args }));
465
+ // Never replay rejected prose: it may contain fabricated user/tool results.
466
+ messages.push(
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.'}` },
468
+ );
469
+ }
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 });
473
+ if (parsed.toolCalls.length > 0) {
474
+ const toolCalls = parsed.toolCalls.map((call) => ({
318
475
  type: 'toolCall',
319
- id: `call_${Date.now().toString(36)}`,
320
- name: parsed.toolCall.name,
321
- arguments: parsed.toolCall.arguments,
322
- };
476
+ id: `call_${Date.now().toString(36)}_${++callSequence}`,
477
+ name: call.name,
478
+ arguments: call.arguments,
479
+ }));
323
480
  const spoken = isContactSpokenPreamble(parsed.chatText) ? parsed.chatText.trim() : '';
481
+ const spokenBubbles = spoken ? splitContactBubbles(spoken) : [];
324
482
  const content = spoken
325
- ? [{ type: 'text', text: spoken }, toolCall]
326
- : [toolCall];
483
+ ? [{ type: 'text', text: spoken }, ...toolCalls]
484
+ : toolCalls;
327
485
  const partial = {
328
486
  ...assistantMessage(model, spoken, 'toolUse'),
329
487
  content,
330
488
  };
331
- if (spoken) bubbleTracker.finish([spoken]);
489
+ if (spokenBubbles.length > 0) bubbleTracker.finish(spokenBubbles);
332
490
  stream.push({ type: 'start', partial });
333
491
  if (spoken) {
334
492
  stream.push({ type: 'text_start', contentIndex: 0, partial });
335
493
  stream.push({ type: 'text_delta', contentIndex: 0, delta: spoken, partial });
336
494
  stream.push({ type: 'text_end', contentIndex: 0, content: spoken, partial });
337
495
  }
338
- const toolIndex = spoken ? 1 : 0;
339
- stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
340
- stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
341
- stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
496
+ toolCalls.forEach((toolCall, index) => {
497
+ const toolIndex = index + (spoken ? 1 : 0);
498
+ stream.push({ type: 'toolcall_start', contentIndex: toolIndex, partial });
499
+ stream.push({ type: 'toolcall_delta', contentIndex: toolIndex, delta: JSON.stringify(toolCall.arguments), partial });
500
+ stream.push({ type: 'toolcall_end', contentIndex: toolIndex, toolCall, partial });
501
+ });
342
502
  stream.push({ type: 'done', reason: 'toolUse', message: partial });
343
503
  stream.end(partial);
344
504
  return;
345
505
  }
346
- const chatText = stripContactToolFences(text);
506
+ const chatText = finalText ?? parsed.chatText;
347
507
  const replyBubbles = splitContactBubbles(chatText);
348
508
  if (bubbleGapMs > 0 && replyBubbles.length > 1) {
349
509
  for (let index = 0; index < replyBubbles.length; index += 1) {
350
- onBubbleDelta?.(index, replyBubbles[index], true);
510
+ onBubbleDelta?.(baseIndex + index, replyBubbles[index], true);
351
511
  if (index < replyBubbles.length - 1) {
352
512
  await new Promise((resolve) => setTimeout(resolve, bubbleGapMs));
353
513
  }
@@ -363,7 +523,7 @@ export function createContactStreamFn(createChatCompletion, {
363
523
  stream.push({ type: 'done', reason: 'stop', message: partial });
364
524
  stream.end(partial);
365
525
  } catch (error) {
366
- const failed = assistantMessage(model, '', 'error', error?.message || 'upstream_error');
526
+ const failed = assistantMessage(model, '', signal?.aborted ? 'aborted' : 'error', error?.message || 'upstream_error');
367
527
  stream.push({ type: 'error', reason: 'error', error: failed });
368
528
  stream.end(failed);
369
529
  }
@@ -378,22 +538,6 @@ const userMessageText = (message) => {
378
538
  return (message?.content || []).map((part) => (typeof part?.text === 'string' ? part.text : '')).join('');
379
539
  };
380
540
 
381
- const assistantTextParts = (message) => (Array.isArray(message?.content) ? message.content : [])
382
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
383
- .map((part) => part.text)
384
- .join('');
385
-
386
- const extractSpokenPreamble = (messages) => {
387
- for (const message of Array.isArray(messages) ? messages : []) {
388
- if (message?.role !== 'assistant') continue;
389
- const parts = Array.isArray(message.content) ? message.content : [];
390
- if (!parts.some((part) => part?.type === 'toolCall')) continue;
391
- const text = assistantTextParts(message).trim();
392
- if (isContactSpokenPreamble(text)) return text;
393
- }
394
- return '';
395
- };
396
-
397
541
  const extractAssistantText = (messages) => {
398
542
  for (let index = messages.length - 1; index >= 0; index -= 1) {
399
543
  const message = messages[index];
@@ -425,65 +569,56 @@ const extractToolResultText = (messages) => {
425
569
  return '';
426
570
  };
427
571
 
428
- /** Tool confirms that are themselves the user-facing bubble (no card). */
429
- const TOOL_TEXT_BUBBLE_TOOLS = new Set([
430
- NEW_CONVERSATION_TOOL_NAME,
431
- CLEAR_CHAT_HISTORY_TOOL_NAME,
432
- UPDATE_DEFAULT_PROMPT_TOOL_NAME,
433
- GET_ASSISTANT_SETTINGS_TOOL_NAME,
434
- ]);
435
-
436
- /** Card / side-effect tools: never paint English toolText into the transcript. */
437
- const CARD_SIDE_EFFECT_TOOLS = new Set([
438
- ASSIGN_SESSION_TOOL_NAME,
439
- CREATE_ASSISTANT_TOOL_NAME,
440
- SCHEDULE_TASK_TOOL_NAME,
441
- MESSAGE_ASSISTANT_TOOL_NAME,
442
- ]);
443
-
444
- const extractContactTurnOutcome = (messages, retried) => {
445
- const list = Array.isArray(messages) ? messages : [];
446
- let start = 0;
447
- if (retried) {
448
- for (let index = list.length - 1; index >= 0; index -= 1) {
449
- if (list[index]?.role === 'user') {
450
- start = index;
451
- break;
452
- }
453
- }
454
- if (start === 0) {
455
- const toolIndex = list.findIndex((message) => message?.role === 'toolResult');
456
- if (toolIndex >= 0) start = toolIndex;
457
- }
458
- }
459
- 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 : [];
460
580
  const cards = extractContactCardsFromMessages(slice);
461
581
  const hasTool = contactTurnHasToolResult(slice);
462
- const spoken = extractSpokenPreamble(slice);
582
+ // Same rules as live SSE indices — keep every independent spoken/final bubble.
583
+ const published = projectStreamedContactTurnBubbles(slice, 0);
463
584
  const lastToolName = [...slice].reverse().find((message) => message?.role === 'toolResult')?.toolName;
464
585
  const coding = isPiCodingToolName(lastToolName);
465
586
  const toolText = extractToolResultText(slice);
466
587
  const assistant = extractAssistantText(slice);
467
- 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 = '';
468
593
  if (hasTool && !coding) {
469
594
  if (TOOL_TEXT_BUBBLE_TOOLS.has(lastToolName)) {
470
- // Confirm-only tools: toolText is the user bubble.
471
- confirm = toolText || assistant;
472
- } else if (CARD_SIDE_EFFECT_TOOLS.has(lastToolName)) {
473
- // Spoken preamble only. Card is enough when present; never English toolText.
474
- // Post-tool assistant text (terminate:false tools) may still surface when no spoken.
475
- confirm = spoken ? '' : (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 = '';
476
599
  } else {
477
600
  // list_projects / list_sessions and other app tools keep toolText.
478
- confirm = toolText || assistant;
601
+ toolOnlyConfirm = toolText || '';
602
+ if (!toolOnlyConfirm && published.length === 0) toolOnlyConfirm = assistant || '';
479
603
  }
480
- } else {
481
- 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);
482
615
  }
483
- const parts = [];
484
- if (spoken) parts.push(spoken);
485
- if (confirm && confirm !== spoken) parts.push(confirm);
486
- return { text: parts.join('\n\n'), cards, hasTool };
616
+ return {
617
+ text: bubbles.join('\n\n'),
618
+ bubbles,
619
+ cards,
620
+ hasTool,
621
+ };
487
622
  };
488
623
 
489
624
  /**
@@ -504,34 +639,45 @@ export async function runContactTurn({
504
639
  tools = [],
505
640
  projects = [],
506
641
  connectedModels = [],
642
+ modelPreferences = null,
643
+ modelCatalogAvailable = true,
507
644
  onTextDelta = null,
508
645
  onBubbleDelta = null,
509
646
  globalEventHub = null,
510
647
  skillHomeDir,
648
+ language = '',
511
649
  AgentImpl = Agent,
650
+ signal = null,
651
+ readOnly = false,
512
652
  }) {
513
653
  const providerID = assistant.providerID;
514
654
  const modelID = assistant.modelID;
515
655
  const cwd = resolveAssistantCwd(assistant);
516
656
  const contactTools = Array.isArray(tools)
517
- ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
657
+ ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name) && (!readOnly || ['list_projects', 'list_sessions', 'read_session', 'get_assistant_settings'].includes(tool.name)))
518
658
  : [];
519
659
  let runtime = null;
660
+ let removeAbortListener = null;
520
661
  try {
521
- if (cwd) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
662
+ signal?.throwIfAborted();
663
+ if (cwd && !readOnly) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
522
664
  const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
523
665
  const systemPrompt = [
524
666
  CONTACT_SYSTEM_PROMPT,
667
+ 'Conversation history is a bounded recent window, not guaranteed complete memory. OpenChamber card context records contain actual session identifiers and status; use them to resolve references to earlier work. Never claim to remember omitted details or repeat completed work because older context is missing. Ask for the missing detail when needed.',
668
+ readOnly ? 'This turn is a background result notification. Only read-only lookup tools are available. Never create or continue work; follow the latest user constraints and report the result briefly.' : '',
525
669
  formatPiCodingPrompt({
526
670
  cwd: runtime?.cwd,
527
671
  skillsPrompt: runtime?.skillsPrompt,
528
672
  tools: codingTools,
529
673
  }),
530
674
  formatRegisteredProjectsPrompt(projects),
531
- formatConnectedModelsPrompt(connectedModels),
675
+ formatConnectedModelsPrompt(connectedModels, { preferences: modelPreferences, catalogAvailable: modelCatalogAvailable }),
532
676
  formatContactToolsPrompt(contactTools),
533
677
  assistant.defaultPrompt,
534
- ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');
678
+ codingTools.length > 0 ? WORKSPACE_RESPONSE_PROTOCOL : '',
679
+ ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n')
680
+ .replaceAll('{{LANGUAGE}}', resolveContactLanguage(language));
535
681
  const model = createContactModel(providerID, modelID);
536
682
  // streamFn receives the model; completions needs providerID/modelID.
537
683
  model.name = `${providerID}/${modelID}`;
@@ -552,8 +698,11 @@ export async function runContactTurn({
552
698
  }
553
699
  ))
554
700
  : [];
701
+ // Fixed turn boundary: every completion and missed-tool retry continues bubble indices from here.
702
+ const turnMessageStart = prior.length;
555
703
 
556
704
  const agent = new AgentImpl({
705
+ toolExecution: 'sequential',
557
706
  initialState: {
558
707
  systemPrompt,
559
708
  model,
@@ -567,10 +716,17 @@ export async function runContactTurn({
567
716
  onBubbleDelta,
568
717
  globalEventHub,
569
718
  bubbleGapMs: 280,
719
+ signal,
720
+ turnMessageStart,
570
721
  }),
571
722
  });
572
723
 
724
+ const onAbort = () => agent.abort();
725
+ signal?.addEventListener('abort', onAbort, { once: true });
726
+ removeAbortListener = () => signal?.removeEventListener('abort', onAbort);
727
+ signal?.throwIfAborted();
573
728
  await agent.prompt(userText);
729
+ signal?.throwIfAborted();
574
730
  if (agent.state.errorMessage) {
575
731
  const error = new Error(agent.state.errorMessage);
576
732
  error.code = 'upstream_error';
@@ -580,10 +736,10 @@ export async function runContactTurn({
580
736
  const hasRequestedResult = () => agent.state.messages.some((message) => (
581
737
  message?.role === 'toolResult' && requested.includes(message.toolName)
582
738
  ));
583
- let retried = false;
584
739
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
585
- retried = true;
740
+ signal?.throwIfAborted();
586
741
  await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
742
+ signal?.throwIfAborted();
587
743
  if (agent.state.errorMessage) {
588
744
  const error = new Error(agent.state.errorMessage);
589
745
  error.code = 'upstream_error';
@@ -592,13 +748,15 @@ export async function runContactTurn({
592
748
  }
593
749
  if (requested.length > 0 && !hasRequestedResult() && !contactTurnHasSuccessfulReset(agent.state.messages)) {
594
750
  // Prefer any spoken/assistant text already in the turn over the English failure fallback.
595
- const missed = extractContactTurnOutcome(agent.state.messages, retried);
596
- const missedText = stripContactToolFences(missed.text).trim();
597
- if (missedText) {
598
- 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) {
599
757
  return {
600
- text: missedText,
601
- bubbles: missedBubbles.length > 0 ? missedBubbles : [missedText],
758
+ text: missedBubbles.join('\n\n'),
759
+ bubbles: missedBubbles,
602
760
  cards: [],
603
761
  thinkingLevel: agent.state.thinkingLevel,
604
762
  tools: [...agent.state.tools],
@@ -612,17 +770,20 @@ export async function runContactTurn({
612
770
  tools: [...agent.state.tools],
613
771
  };
614
772
  }
615
- const outcome = extractContactTurnOutcome(agent.state.messages, retried);
616
- const text = stripContactToolFences(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 || '';
617
777
  if (contactTurnHasSuccessfulReset(agent.state.messages)) {
618
778
  const historyCleared = contactTurnClearedChatHistory(agent.state.messages);
619
779
  const preferredConfirm = historyCleared
620
780
  ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
621
781
  : NEW_CONVERSATION_CONFIRM_BUBBLE;
622
- 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);
623
784
  return {
624
- text: bubbles[0] || preferredConfirm,
625
- bubbles,
785
+ text: resetBubbles[0] || preferredConfirm,
786
+ bubbles: resetBubbles,
626
787
  cards: [],
627
788
  reset: true,
628
789
  historyCleared,
@@ -636,14 +797,16 @@ export async function runContactTurn({
636
797
  error.code = 'upstream_error';
637
798
  throw error;
638
799
  }
800
+ // Published stream indices are a stable prefix of the final array on normal turns.
639
801
  return {
640
802
  text,
641
- bubbles: splitContactBubbles(text),
803
+ bubbles,
642
804
  cards: outcome.cards,
643
805
  thinkingLevel: agent.state.thinkingLevel,
644
806
  tools: [...agent.state.tools],
645
807
  };
646
808
  } finally {
809
+ removeAbortListener?.();
647
810
  try {
648
811
  await runtime?.close?.();
649
812
  } catch {