@openchambery/web 1.19.11 → 1.19.13-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 (123) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
  2. package/dist/assets/AssistantView-YmeyxehG.js +2 -0
  3. package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
  15. package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
  20. package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
  21. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
  22. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
  23. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
  24. package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
  26. package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
  27. package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
  28. package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
  29. package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
  30. package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
  31. package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
  34. package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
  35. package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
  36. package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
  38. package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
  39. package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
  40. package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
  41. package/dist/assets/miniChat-B9c3y2pD.js +2 -0
  42. package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
  44. package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
  45. package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
  46. package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
  47. package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
  48. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
  53. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
  54. package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
  55. package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
  56. package/dist/assets/src-BpHDMgud.css +2 -0
  57. package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
  58. package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
  59. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
  60. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
  61. package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
  62. package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
  70. package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
  71. package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
  72. package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
  73. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
  74. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.js +19 -0
  80. package/server/lib/assistants/DOCUMENTATION.md +21 -6
  81. package/server/lib/assistants/assign.js +455 -30
  82. package/server/lib/assistants/assign.test.js +351 -0
  83. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  84. package/server/lib/assistants/contact-store.js +64 -0
  85. package/server/lib/assistants/contact-store.test.js +12 -0
  86. package/server/lib/assistants/contact-tools.js +216 -25
  87. package/server/lib/assistants/contact-tools.test.js +167 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +20 -3
  90. package/server/lib/assistants/harness.test.js +120 -1
  91. package/server/lib/assistants/pi-tools.js +50 -3
  92. package/server/lib/assistants/pi-tools.test.js +25 -0
  93. package/server/lib/assistants/service.js +320 -27
  94. package/server/lib/assistants/service.test.js +581 -9
  95. package/server/lib/llm/DOCUMENTATION.md +31 -18
  96. package/server/lib/llm/catalog.js +91 -16
  97. package/server/lib/llm/catalog.test.js +152 -11
  98. package/server/lib/llm/generate.js +43 -7
  99. package/server/lib/llm/generate.test.js +98 -1
  100. package/server/lib/llm/temp-directory.js +34 -13
  101. package/server/lib/llm/temp-directory.test.js +57 -0
  102. package/server/lib/notifications/APNS.md +20 -4
  103. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  104. package/server/lib/notifications/apns-runtime.js +136 -18
  105. package/server/lib/notifications/apns-runtime.test.js +128 -0
  106. package/server/lib/notifications/apns-titles.js +21 -1
  107. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  108. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  109. package/server/lib/notifications/runtime.js +93 -2
  110. package/server/lib/notifications/runtime.test.js +167 -4
  111. package/server/lib/opencode/settings-helpers.js +12 -0
  112. package/server/lib/opencode/settings-runtime.js +16 -0
  113. package/server/lib/opencode/settings-runtime.test.js +21 -0
  114. package/server/lib/scheduled-tasks/runtime.js +21 -0
  115. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  116. package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
  117. package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
  118. package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
  119. package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
  120. package/dist/assets/miniChat-BJ9AclZX.js +0 -2
  121. package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
  122. package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
  123. package/dist/assets/src-B-P5qIBc.css +0 -2
@@ -1,4 +1,4 @@
1
- import { AssignError, PROJECT_REQUIRED_MESSAGE } from './assign.js';
1
+ import { AssignError, ASSIGN_CODES, PROJECT_REQUIRED_MESSAGE } from './assign.js';
2
2
  import { createAssistantCardPart, createScheduleCardPart, createSessionCardPart } from './cards.js';
3
3
  import { isPiCodingToolName } from './pi-tools.js';
4
4
 
@@ -32,6 +32,8 @@ export const LIST_PROJECTS_TOOL_NAME = 'list_projects';
32
32
  export const LIST_SESSIONS_TOOL_NAME = 'list_sessions';
33
33
  const CONTACT_TOOL_FENCE = 'openchamber-tool';
34
34
  export const ASSIGNED_SESSION_FALLBACK_BUBBLE = 'Opened a coding session.';
35
+ /** Same-turn duplicate assign (different args after a success, or parallel mismatch). */
36
+ export const ASSIGN_DUPLICATE_TURN_MESSAGE = 'This contact turn already opened a coding session. Do not assign again.';
35
37
  /** Clear-memory confirm: transcript rows stay; only the LLM window resets. */
36
38
  export const NEW_CONVERSATION_CONFIRM_BUBBLE = 'Memory cleared. Previous messages stay in the chat; I will not use them as context.';
37
39
  /** Explicit transcript wipe confirm (clear_chat_history / POST contact/reset). */
@@ -39,6 +41,30 @@ export const CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE = 'Chat history cleared.';
39
41
  const LIST_SESSIONS_LIMIT_DEFAULT = 20;
40
42
  const LIST_SESSIONS_LIMIT_MAX = 50;
41
43
 
44
+ /** Stable key for same-turn assign dedup (not cross-turn). Includes worker model + attachment scope. */
45
+ export function normalizeAssignRequestKey(params = {}) {
46
+ const pick = (value) => {
47
+ if (typeof value === 'string') return value.trim();
48
+ if (value == null) return '';
49
+ return String(value).trim();
50
+ };
51
+ const attachments = Array.isArray(params?.attachmentScope)
52
+ ? params.attachmentScope
53
+ : (Array.isArray(params?.attachments) ? params.attachments : []);
54
+ return JSON.stringify({
55
+ prompt: pick(params?.prompt),
56
+ projectPath: pick(params?.projectPath),
57
+ directory: pick(params?.directory),
58
+ branch: pick(params?.branch),
59
+ sessionID: pick(params?.sessionID),
60
+ title: pick(params?.title),
61
+ providerID: pick(params?.providerID),
62
+ modelID: pick(params?.modelID),
63
+ model: pick(params?.model),
64
+ attachments,
65
+ });
66
+ }
67
+
42
68
  const DENIED_CODING_TOOLS = new Set(['glob', 'grep', 'shell', 'find', 'ls', 'powershell']);
43
69
 
44
70
  const FENCE = new RegExp(`\`\`\`${CONTACT_TOOL_FENCE}\\s*([\\s\\S]*?)\`\`\``, 'u');
@@ -61,12 +87,16 @@ const newConversationParameters = typeboxObject({});
61
87
  const clearChatHistoryParameters = typeboxObject({});
62
88
 
63
89
  const assignParameters = typeboxObject({
64
- prompt: typeboxString('Coding prompt to kick into the worker OpenCode session.'),
90
+ prompt: typeboxString('Coding prompt to kick into the worker OpenCode session. Current-turn user attachments (images/files) are forwarded by the server automatically — do not base64-encode or invent local paths.'),
65
91
  projectPath: typeboxOptional(typeboxString('Registered project path. Required when more than one project exists.')),
66
92
  directory: typeboxOptional(typeboxString('Existing project or worktree directory to reuse.')),
67
93
  branch: typeboxOptional(typeboxString('Existing Chat worktree branch to reuse. Do not create a new worktree.')),
68
94
  sessionID: typeboxOptional(typeboxString('Existing OpenCode session to reuse instead of creating one.')),
69
95
  title: typeboxOptional(typeboxString('Optional title for the worker session and contact card.')),
96
+ providerID: typeboxOptional(typeboxString('Optional worker OpenCode provider ID from the connected catalog. Does not change this contact\'s model. Illegal/blank values fail closed.')),
97
+ modelID: typeboxOptional(typeboxString('Optional worker OpenCode model ID from the connected catalog. Does not change this contact\'s model. Illegal/blank values fail closed.')),
98
+ model: typeboxOptional(typeboxString('Optional worker provider/model string such as provider/model-id from the connected catalog. Must not conflict with providerID/modelID. Does not change this contact\'s model.')),
99
+ variant: typeboxOptional(typeboxString('Optional worker variant for this assign only. Cross-model assign never reuses this contact\'s variant.')),
70
100
  });
71
101
 
72
102
  const createAssistantParameters = typeboxObject({
@@ -181,6 +211,47 @@ export function formatRegisteredProjectsPrompt(projects) {
181
211
  ].join('\n');
182
212
  }
183
213
 
214
+ /** Sanitize connected catalog rows for prompt/tool consumption (no secrets). */
215
+ function sanitizeConnectedModel(entry) {
216
+ if (!entry || typeof entry !== 'object') return null;
217
+ const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
218
+ const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
219
+ if (!providerID || !modelID) return null;
220
+ const name = typeof entry.name === 'string' && entry.name.trim() ? entry.name.trim() : modelID;
221
+ return {
222
+ providerID,
223
+ modelID,
224
+ name,
225
+ acceptsImages: entry.acceptsImages === true,
226
+ };
227
+ }
228
+
229
+ export function normalizeConnectedModels(models) {
230
+ return (Array.isArray(models) ? models : [])
231
+ .map(sanitizeConnectedModel)
232
+ .filter(Boolean);
233
+ }
234
+
235
+ /** Injected each turn so assign_session can pick an explicit worker model from the live catalog. */
236
+ export function formatConnectedModelsPrompt(models) {
237
+ const list = normalizeConnectedModels(models);
238
+ if (list.length === 0) {
239
+ return [
240
+ 'Connected OpenCode models: none discoverable this turn.',
241
+ 'assign_session without providerID/modelID/model still uses this contact\'s default model.',
242
+ 'If the user names a specific worker model, only use ids from a successful catalog load — never invent provider/model strings.',
243
+ ].join(' ');
244
+ }
245
+ return [
246
+ 'Connected OpenCode models (worker targets for assign_session only — choosing one does NOT change this contact\'s own model):',
247
+ ...list.map((entry) => (
248
+ `- providerID=${JSON.stringify(entry.providerID)} modelID=${JSON.stringify(entry.modelID)} name=${JSON.stringify(entry.name)} acceptsImages=${entry.acceptsImages ? 'true' : 'false'}`
249
+ )),
250
+ 'When the user names a worker model, pass providerID+modelID or model="provider/modelID" on assign_session. Ambiguous or unknown names must fail — never silent-fallback.',
251
+ 'Current-turn user images/files are forwarded by the server on assign_session. Prefer a acceptsImages=true model when the user attached images.',
252
+ ].join('\n');
253
+ }
254
+
184
255
  export function boundSessionListLimit(value) {
185
256
  if (value == null || value === '') return LIST_SESSIONS_LIMIT_DEFAULT;
186
257
  const parsed = typeof value === 'number' ? value : Number(String(value).trim());
@@ -414,6 +485,35 @@ export function resolvePeerAssistant(params = {}, assistants = [], currentAssist
414
485
  return matches[0];
415
486
  }
416
487
 
488
+ /**
489
+ * Serialize a tool parameter schema for the contact system prompt.
490
+ * Accepts plain JSON-schema-like objects and TypeBox schemas (JSON.stringify).
491
+ * Strips non-enumerable TypeBox markers (~kind / ~optional).
492
+ */
493
+ export function serializeToolParametersForPrompt(parameters) {
494
+ if (parameters == null) return '{"type":"object","properties":{}}';
495
+ try {
496
+ const text = JSON.stringify(parameters, (_key, value) => {
497
+ if (typeof value === 'function' || typeof value === 'symbol') return undefined;
498
+ return value;
499
+ });
500
+ if (typeof text === 'string' && text.trim() && text !== 'undefined') return text;
501
+ } catch {
502
+ // Fall through to empty object schema.
503
+ }
504
+ return '{"type":"object","properties":{}}';
505
+ }
506
+
507
+ /** One catalog line: name, description, and full arguments schema. */
508
+ export function formatApplicationToolCatalogEntry(tool) {
509
+ if (!tool || typeof tool.name !== 'string' || !tool.name.trim()) return '';
510
+ const name = tool.name.trim();
511
+ const description = typeof tool.description === 'string' && tool.description.trim()
512
+ ? tool.description.trim()
513
+ : (typeof tool.label === 'string' && tool.label.trim() ? tool.label.trim() : name);
514
+ return `- ${name}: ${description}\n arguments schema: ${serializeToolParametersForPrompt(tool.parameters)}`;
515
+ }
516
+
417
517
  export function formatContactToolsPrompt(tools) {
418
518
  const list = Array.isArray(tools)
419
519
  ? tools.filter((tool) => tool?.name && !DENIED_CODING_TOOLS.has(tool.name) && !isPiCodingToolName(tool.name))
@@ -452,14 +552,15 @@ export function formatContactToolsPrompt(tools) {
452
552
  `{"name":"${MESSAGE_ASSISTANT_TOOL_NAME}","arguments":{"to":"PeerQA","text":"hello-from-assistant 写好了"}}`,
453
553
  '```',
454
554
  `\`\`\`${CONTACT_TOOL_FENCE}`,
455
- `{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"..."}}`,
555
+ `{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"...","model":"provider/model-id"}}`,
456
556
  '```',
457
557
  'If the user asked for more than one of these, do them in that order across turns: new_conversation or clear_chat_history, then list_projects, then list_sessions, then create_assistant, then schedule_task, then message_assistant, then assign_session.',
558
+ 'Prerequisite lookups (list_projects / list_sessions) may run before assign_session in the same turn. After a successful assign_session the turn ends — never call assign_session again, and do not keep looping tools.',
458
559
  'new_conversation advances this contact\'s LLM context boundary only. Stored messages and watches remain. It never calls session/new or createNew.',
459
560
  'clear_chat_history deletes this contact\'s stored messages, parts, and watches. Use only for explicit wipe intent.',
460
561
  'You already receive the registered project catalog each turn. Prefer matching label/path yourself; list_projects refreshes or filters. Never claim you cannot see projects; never ask for a raw path when a name matches.',
461
562
  'list_sessions searches the OpenChamber session index for existing chats in a project. A failure is not an empty list — surface the error.',
462
- 'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker.',
563
+ 'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker. Optional providerID/modelID/model select the worker only from the connected catalog and never change this contact. Current-turn user attachments are server-forwarded — do not embed base64 or local paths. One successful assign ends this turn.',
463
564
  'create_assistant reuses already-connected OpenCode providers (providerID/modelID). Mode is continuous.',
464
565
  'schedule_task writes the same payload as PUT /api/projects/:id/scheduled-tasks onto a registered project.',
465
566
  'message_assistant is read-only: it inserts into the other contact transcript. It never runs promptAsync or mutates sessions or files. Never assign through a peer message.',
@@ -467,9 +568,10 @@ export function formatContactToolsPrompt(tools) {
467
568
  'When calling a tool that takes a beat (找项目 / 开会话), you may say one short spoken line first (≤40 characters, e.g. 我去找一下), then only the fence. No planning, no tool names, no "let me think".',
468
569
  'Never say 已创建, 已发送, created, scheduled, opened, or sent unless the tool already returned success.',
469
570
  'If no registered project exists, tell the user to add one in Settings — do not use assistant-workspaces.',
470
- 'After a successful tool, confirm in one short bubble. The user sees a contact card, not tool traces.',
571
+ 'After a successful tool, confirm in one short bubble. The user sees a contact card, not tool traces. After successful assign_session the session card plus that short confirm is enough — stop.',
572
+ 'These are application-owned OpenChamber contact tools (not OpenCode native tools, not MCP, not skill directory entries). Use only the names and argument schemas below:',
471
573
  'Available OpenChamber tools:',
472
- ...list.map((tool) => `- ${tool.name}: ${tool.description || tool.label || tool.name}`),
574
+ ...list.map((tool) => formatApplicationToolCatalogEntry(tool)).filter(Boolean),
473
575
  ].join('\n');
474
576
  }
475
577
 
@@ -529,9 +631,17 @@ const toolFailure = (error, fallbackCode, fallbackMessage) => {
529
631
  const message = typeof error?.message === 'string' && error.message.trim()
530
632
  ? error.message.trim()
531
633
  : fallbackMessage;
634
+ const details = { error: code };
635
+ // Recoverable prompt admission identity — model must reuse sessionID/messageID,
636
+ // never blind-create another worker on the same contact turn.
637
+ if (error instanceof AssignError || (error && typeof error === 'object')) {
638
+ if (error.ambiguous === true) details.ambiguous = true;
639
+ if (typeof error.sessionID === 'string' && error.sessionID) details.sessionID = error.sessionID;
640
+ if (typeof error.messageID === 'string' && error.messageID) details.messageID = error.messageID;
641
+ }
532
642
  return {
533
643
  content: [{ type: 'text', text: message }],
534
- details: { error: code },
644
+ details,
535
645
  terminate: true,
536
646
  };
537
647
  };
@@ -548,11 +658,106 @@ export function createContactTools({
548
658
  listSessions,
549
659
  currentAssistant,
550
660
  onCard,
661
+ /** Authoritative current-turn user file parts (server-owned). Forwarded on assign by default. */
662
+ turnFileParts = [],
663
+ /** Precomputed attachment fingerprint for same-turn assign dedup. */
664
+ turnAttachmentScope = [],
551
665
  } = {}) {
552
666
  const emitCard = (card) => {
553
667
  if (typeof onCard === 'function') onCard(card);
554
668
  };
555
669
 
670
+ /**
671
+ * Same contact-turn assign gate only (tools instance = one harness turn).
672
+ * - Same args after success → cached success (no second worker).
673
+ * - Different args after success / parallel mismatch → reject.
674
+ * - Definitive failure clears the gate so a corrected retry may run.
675
+ * - prompt_ambiguous keeps the gate with sessionID/messageID so a retry must
676
+ * reuse that worker — never blind-create a second session this turn.
677
+ * Not cross-turn.
678
+ */
679
+ let assignTurnGate = null;
680
+
681
+ const executeAssignOnce = async (params) => {
682
+ let request = { ...(params || {}) };
683
+ const key = normalizeAssignRequestKey({
684
+ ...request,
685
+ attachmentScope: turnAttachmentScope,
686
+ });
687
+ if (assignTurnGate?.status === 'done') {
688
+ if (assignTurnGate.key === key) return assignTurnGate.result;
689
+ throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
690
+ }
691
+ if (assignTurnGate?.status === 'pending') {
692
+ if (assignTurnGate.key === key) return assignTurnGate.promise;
693
+ throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
694
+ }
695
+ if (assignTurnGate?.status === 'ambiguous') {
696
+ if (assignTurnGate.key !== key) {
697
+ throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
698
+ }
699
+ // Same request after ambiguous admission: reuse worker identity only.
700
+ if (assignTurnGate.sessionID) request = { ...request, sessionID: assignTurnGate.sessionID };
701
+ if (assignTurnGate.messageID) request = { ...request, messageID: assignTurnGate.messageID };
702
+ }
703
+
704
+ let settle;
705
+ const promise = new Promise((resolve, reject) => {
706
+ settle = { resolve, reject };
707
+ });
708
+ assignTurnGate = { status: 'pending', key, promise };
709
+
710
+ void (async () => {
711
+ try {
712
+ if (typeof assignWork !== 'function') {
713
+ throw new AssignError('upstream_error', 'Assign is unavailable.');
714
+ }
715
+ // Server owns current-turn attachments — model never supplies base64/paths.
716
+ const assigned = await assignWork({
717
+ ...request,
718
+ fileParts: turnFileParts,
719
+ attachmentScope: turnAttachmentScope,
720
+ });
721
+ const card = createSessionCardPart({
722
+ sessionID: assigned.sessionID,
723
+ directory: assigned.directory,
724
+ title: assigned.title,
725
+ status: assigned.status || 'busy',
726
+ branch: assigned.branch,
727
+ });
728
+ emitCard(card);
729
+ const result = {
730
+ // Short confirm for the transcript; session id lives on the card details.
731
+ content: [{ type: 'text', text: ASSIGNED_SESSION_FALLBACK_BUBBLE }],
732
+ details: { card, assigned },
733
+ // End the pi agent loop so a looping model cannot re-assign 37 times.
734
+ terminate: true,
735
+ };
736
+ assignTurnGate = { status: 'done', key, result };
737
+ settle.resolve(result);
738
+ } catch (error) {
739
+ if (
740
+ error instanceof AssignError
741
+ && error.code === ASSIGN_CODES.PROMPT_AMBIGUOUS
742
+ && typeof error.sessionID === 'string'
743
+ && error.sessionID
744
+ ) {
745
+ assignTurnGate = {
746
+ status: 'ambiguous',
747
+ key,
748
+ sessionID: error.sessionID,
749
+ messageID: typeof error.messageID === 'string' ? error.messageID : null,
750
+ };
751
+ } else {
752
+ assignTurnGate = null;
753
+ }
754
+ settle.reject(error);
755
+ }
756
+ })();
757
+
758
+ return promise;
759
+ };
760
+
556
761
  return [
557
762
  {
558
763
  name: NEW_CONVERSATION_TOOL_NAME,
@@ -816,28 +1021,14 @@ export function createContactTools({
816
1021
  description: [
817
1022
  'Open or reuse a real OpenChamber coding session on a registered project path',
818
1023
  'and kick the prompt into that session. Optional existing worktree branch or sessionID.',
819
- 'Never codes here. Never uses assistant-workspaces.',
1024
+ 'Optional providerID/modelID/model select the worker from the connected catalog only (does not change this contact).',
1025
+ 'Current-turn user attachments are forwarded by the server automatically.',
1026
+ 'Successful assign ends this contact turn (terminate). Never codes here. Never uses assistant-workspaces.',
820
1027
  ].join(' '),
821
1028
  parameters: assignParameters,
822
1029
  execute: async (_toolCallId, params) => {
823
1030
  try {
824
- if (typeof assignWork !== 'function') {
825
- throw new AssignError('upstream_error', 'Assign is unavailable.');
826
- }
827
- const assigned = await assignWork(params || {});
828
- const card = createSessionCardPart({
829
- sessionID: assigned.sessionID,
830
- directory: assigned.directory,
831
- title: assigned.title,
832
- status: assigned.status || 'busy',
833
- branch: assigned.branch,
834
- });
835
- emitCard(card);
836
- return {
837
- content: [{ type: 'text', text: `Opened coding session ${assigned.sessionID}. The user will see a session card.` }],
838
- details: { card, assigned },
839
- terminate: false,
840
- };
1031
+ return await executeAssignOnce(params || {});
841
1032
  } catch (error) {
842
1033
  return toolFailure(error, 'assign_failed', 'Could not assign a coding session.');
843
1034
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { AssignError, ASSIGN_CODES, PROJECT_REQUIRED_MESSAGE } from './assign.js';
3
3
  import {
4
+ ASSIGN_DUPLICATE_TURN_MESSAGE,
4
5
  ASSIGN_SESSION_TOOL_NAME,
5
6
  CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
6
7
  CLEAR_CHAT_HISTORY_TOOL_NAME,
@@ -15,15 +16,18 @@ import {
15
16
  createContactTools,
16
17
  detectRequestedContactTools,
17
18
  filterRegisteredProjects,
19
+ formatConnectedModelsPrompt,
18
20
  formatContactToolsPrompt,
19
21
  formatRegisteredProjectsPrompt,
20
22
  matchesProjectQuery,
23
+ normalizeAssignRequestKey,
21
24
  parseContactToolCalls,
22
25
  resolveContactProviderModel,
23
26
  resolvePeerAssistant,
24
27
  stripContactToolFences,
25
28
  userTextAuthorizesClearChatHistory,
26
29
  } from './contact-tools.js';
30
+ import { attachmentScopeKey } from './assign.js';
27
31
 
28
32
  describe('contact tool protocol', () => {
29
33
  it('parses an assign_session fence and strips it from chat text', () => {
@@ -160,6 +164,43 @@ describe('contact tool protocol', () => {
160
164
  expect(prompt).toContain('A reply without the tool call does nothing');
161
165
  expect(prompt).toContain('已创建');
162
166
  expect(prompt).toContain('Never claim you cannot see projects');
167
+ expect(prompt).toContain('One successful assign ends this turn');
168
+ expect(prompt).toContain('application-owned OpenChamber contact tools');
169
+ expect(prompt).toContain('arguments schema:');
170
+ expect(prompt).toContain('"prompt"');
171
+ expect(prompt).toContain('"projectPath"');
172
+ expect(prompt).toContain('"providerID"');
173
+ expect(prompt).toContain('"modelID"');
174
+ expect(prompt).toContain('"model"');
175
+ expect(prompt).toContain('connected catalog');
176
+ expect(prompt).toContain('"to"');
177
+ expect(prompt).toContain('"text"');
178
+ });
179
+
180
+ it('exposes connected models for assign worker selection without inventing ids', () => {
181
+ const prompt = formatConnectedModelsPrompt([
182
+ { providerID: 'xai', modelID: 'grok-4.6', name: 'Grok 4.6', acceptsImages: true },
183
+ { providerID: 'opencode-go', modelID: 'deepseek-v4-flash', acceptsImages: false },
184
+ ]);
185
+ expect(prompt).toContain('xai');
186
+ expect(prompt).toContain('grok-4.6');
187
+ expect(prompt).toContain('acceptsImages=true');
188
+ expect(prompt).toContain('does NOT change this contact');
189
+ expect(formatConnectedModelsPrompt([])).toContain('none discoverable');
190
+ });
191
+
192
+ it('includes worker model and attachment scope in same-turn assign keys', () => {
193
+ const scope = attachmentScopeKey([
194
+ { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'a.png' },
195
+ ]);
196
+ const base = { prompt: 'Fix', projectPath: '/repo' };
197
+ expect(normalizeAssignRequestKey(base)).not.toBe(normalizeAssignRequestKey({
198
+ ...base,
199
+ model: 'xai/grok-4.6',
200
+ }));
201
+ expect(normalizeAssignRequestKey({ ...base, attachmentScope: scope })).not.toBe(
202
+ normalizeAssignRequestKey({ ...base, attachmentScope: [] }),
203
+ );
163
204
  });
164
205
 
165
206
  it('fuzzy-matches project labels like openchamber yee / openchamer yee', () => {
@@ -292,9 +333,135 @@ describe('createContactTools', () => {
292
333
  sessionID: 'ses_1',
293
334
  title: 'Login',
294
335
  });
336
+ expect(assigned.terminate).toBe(true);
295
337
  expect(onCard).toHaveBeenCalledTimes(3);
296
338
  });
297
339
 
340
+ it('forwards server turn file parts on assign and keeps model selection in the tool args', async () => {
341
+ const image = { type: 'file', mime: 'image/png', url: 'data:image/png;base64,aa', filename: 'shot.png' };
342
+ const assignWork = vi.fn(async (params) => ({
343
+ sessionID: 'ses_img',
344
+ directory: '/repo',
345
+ title: 'Shot',
346
+ status: 'busy',
347
+ model: { providerID: params.providerID, modelID: params.modelID },
348
+ }));
349
+ const tools = createContactTools({
350
+ assignWork,
351
+ turnFileParts: [image],
352
+ turnAttachmentScope: attachmentScopeKey([image]),
353
+ });
354
+ const result = await tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME).execute('call_img', {
355
+ prompt: 'Fix card width',
356
+ projectPath: '/repo',
357
+ providerID: 'xai',
358
+ modelID: 'grok-4.6',
359
+ });
360
+ expect(assignWork).toHaveBeenCalledWith(expect.objectContaining({
361
+ prompt: 'Fix card width',
362
+ providerID: 'xai',
363
+ modelID: 'grok-4.6',
364
+ fileParts: [image],
365
+ }));
366
+ expect(result.terminate).toBe(true);
367
+ });
368
+
369
+ it('same-turn assign gate caches identical success and rejects a different second assign', async () => {
370
+ const assignWork = vi.fn(async () => ({
371
+ sessionID: 'ses_once',
372
+ directory: '/repo',
373
+ title: 'Once',
374
+ status: 'busy',
375
+ }));
376
+ const tools = createContactTools({ assignWork });
377
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
378
+ const args = { prompt: 'Fix login', projectPath: '/repo' };
379
+ const first = await assign.execute('call_a', args);
380
+ const secondSame = await assign.execute('call_b', { ...args });
381
+ expect(assignWork).toHaveBeenCalledTimes(1);
382
+ expect(secondSame.details.assigned.sessionID).toBe('ses_once');
383
+ expect(secondSame.terminate).toBe(true);
384
+ expect(normalizeAssignRequestKey(args)).toBe(normalizeAssignRequestKey({ ...args, title: ' ' }));
385
+ const different = await assign.execute('call_c', { prompt: 'Other work', projectPath: '/repo' });
386
+ expect(different.details.error).toBe('validation_error');
387
+ expect(different.content[0].text).toContain(ASSIGN_DUPLICATE_TURN_MESSAGE);
388
+ expect(different.terminate).toBe(true);
389
+ expect(assignWork).toHaveBeenCalledTimes(1);
390
+ });
391
+
392
+ it('same-turn assign gate treats different worker models as distinct requests after success', async () => {
393
+ const assignWork = vi.fn(async () => ({
394
+ sessionID: 'ses_model',
395
+ directory: '/repo',
396
+ title: 'M',
397
+ status: 'busy',
398
+ }));
399
+ const tools = createContactTools({ assignWork });
400
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
401
+ await assign.execute('call_1', { prompt: 'Fix', projectPath: '/repo', model: 'xai/grok-4.6' });
402
+ const second = await assign.execute('call_2', { prompt: 'Fix', projectPath: '/repo', model: 'openai/gpt-4o' });
403
+ expect(second.details.error).toBe('validation_error');
404
+ expect(assignWork).toHaveBeenCalledTimes(1);
405
+ });
406
+
407
+ it('prompt_ambiguous keeps recoverable sessionID/messageID and blocks a different create this turn', async () => {
408
+ let calls = 0;
409
+ const assignWork = vi.fn(async (params) => {
410
+ calls += 1;
411
+ if (calls === 1) {
412
+ throw new AssignError(
413
+ ASSIGN_CODES.PROMPT_AMBIGUOUS,
414
+ 'admission unknown',
415
+ { ambiguous: true, sessionID: 'ses_amb', messageID: 'msg_amb' },
416
+ );
417
+ }
418
+ expect(params.sessionID).toBe('ses_amb');
419
+ expect(params.messageID).toBe('msg_amb');
420
+ return { sessionID: 'ses_amb', directory: '/repo', title: 'A', status: 'busy', messageID: 'msg_amb' };
421
+ });
422
+ const tools = createContactTools({ assignWork });
423
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
424
+ const args = { prompt: 'Fix', projectPath: '/repo' };
425
+ const failed = await assign.execute('call_a', args);
426
+ expect(failed.details).toMatchObject({
427
+ error: 'prompt_ambiguous',
428
+ ambiguous: true,
429
+ sessionID: 'ses_amb',
430
+ messageID: 'msg_amb',
431
+ });
432
+ expect(failed.terminate).toBe(true);
433
+ const other = await assign.execute('call_b', { prompt: 'Other', projectPath: '/repo' });
434
+ expect(other.details.error).toBe('validation_error');
435
+ expect(assignWork).toHaveBeenCalledTimes(1);
436
+ const recovered = await assign.execute('call_c', args);
437
+ expect(assignWork).toHaveBeenCalledTimes(2);
438
+ expect(recovered.details.assigned.sessionID).toBe('ses_amb');
439
+ expect(recovered.terminate).toBe(true);
440
+ });
441
+
442
+ it('same-turn parallel identical assigns share one worker create', async () => {
443
+ let release;
444
+ const barrier = new Promise((resolve) => { release = resolve; });
445
+ const assignWork = vi.fn(async () => {
446
+ await barrier;
447
+ return { sessionID: 'ses_parallel', directory: '/repo', title: 'P', status: 'busy' };
448
+ });
449
+ const tools = createContactTools({ assignWork });
450
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
451
+ const args = { prompt: 'Fix', projectPath: '/repo' };
452
+ const pending = Promise.all([
453
+ assign.execute('call_1', args),
454
+ assign.execute('call_2', args),
455
+ ]);
456
+ release();
457
+ const [a, b] = await pending;
458
+ expect(assignWork).toHaveBeenCalledTimes(1);
459
+ expect(a.details.assigned.sessionID).toBe('ses_parallel');
460
+ expect(b.details.assigned.sessionID).toBe('ses_parallel');
461
+ expect(a.terminate).toBe(true);
462
+ expect(b.terminate).toBe(true);
463
+ });
464
+
298
465
  it('resolves a peer by name and rejects a missing recipient', () => {
299
466
  const listed = [{ id: 'asst_peer', name: 'PeerQA' }, { id: 'asst_host', name: 'DeepSeekQA' }];
300
467
  expect(resolvePeerAssistant({ to: 'PeerQA' }, listed, { id: 'asst_host' })).toEqual(listed[0]);
@@ -1,8 +1,8 @@
1
1
  export const assistantContractFixtures = Object.freeze({
2
- assistant: Object.freeze({ id: 'assistant_fixture', revision: 4, enabled: true, name: 'Fixture', defaultPrompt: '', workspacePath: null, managedWorkspacePath: '/data/assistant-workspaces/assistant_fixture', effectiveWorkspacePath: '/workspace', providerID: 'provider_fixture', modelID: 'model_fixture', agent: null, variant: null, mode: 'continuous', sessionID: 'ses_fixture', sessionGeneration: 4, historySessionIDs: Object.freeze([]), historySessionCount: 0, assignedSessionIDs: Object.freeze([]), working: false, createdAt: 1234, updatedAt: 1234, tombstoneAt: null }),
2
+ assistant: Object.freeze({ id: 'assistant_fixture', revision: 4, enabled: true, name: 'Fixture', defaultPrompt: '', workspacePath: null, managedWorkspacePath: '/data/assistant-workspaces/assistant_fixture', effectiveWorkspacePath: '/workspace', providerID: 'provider_fixture', modelID: 'model_fixture', agent: null, variant: null, mode: 'continuous', sessionID: 'ses_fixture', sessionGeneration: 4, historySessionIDs: Object.freeze([]), historySessionCount: 0, assignedSessionIDs: Object.freeze([]), working: false, activeContactTurn: null, createdAt: 1234, updatedAt: 1234, tombstoneAt: null }),
3
3
  sessionBinding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }),
4
4
  compactResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), summarized: true }),
5
- messageAdmission: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), messageID: 'msg_fixture', admitted: true }),
5
+ messageAdmission: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), messageID: 'msg_fixture', admitted: true, revision: 4 }),
6
6
  abortResponse: Object.freeze({ binding: Object.freeze({ sessionID: 'ses_fixture', directory: '/workspace', sessionGeneration: 4 }), aborted: true }),
7
7
  shareOperation: Object.freeze({ operationID: 'share_fixture', assistantID: 'assistant_fixture', sessionID: 'ses_fixture', messageID: 'msg_fixture', state: 'running', phase: 'submitted', attempt: 1, leaseExpiresAt: 1234, errorCode: null }),
8
8
  historicalMessages: Object.freeze({ entries: Object.freeze([Object.freeze({ sessionID: 'ses_fixture', directory: null, info: Object.freeze({ id: 'msg_fixture', sessionID: 'ses_fixture', role: 'assistant', time: Object.freeze({ created: 1234 }) }), parts: Object.freeze([]) })]), nextCursor: null, complete: true }),
@@ -9,6 +9,7 @@ import {
9
9
  detectRequestedContactTools,
10
10
  extractContactCardsFromMessages,
11
11
  formatContactToolsPrompt,
12
+ formatConnectedModelsPrompt,
12
13
  formatRegisteredProjectsPrompt,
13
14
  MISSED_FENCE_RETRY_USER_TEXT,
14
15
  MISSED_TOOL_FAILURE_BUBBLE,
@@ -79,7 +80,7 @@ export const CONTACT_SYSTEM_PROMPT = [
79
80
  '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.',
80
81
  '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 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.',
81
82
  '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.',
82
- '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.',
83
+ '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.',
83
84
  'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
84
85
  ].join(' ');
85
86
 
@@ -259,7 +260,17 @@ export function createContactStreamFn(createChatCompletion, {
259
260
  .filter((part) => part?.type === 'text')
260
261
  .map((part) => part.text)
261
262
  .join('');
262
- return content ? [{ role: 'user', content: `OpenChamber tool ${message.toolName || 'result'}: ${content}` }] : [];
263
+ if (!content) return [];
264
+ const toolName = typeof message.toolName === 'string' && message.toolName.trim()
265
+ ? message.toolName.trim()
266
+ : 'result';
267
+ const callId = typeof message.toolCallId === 'string' && message.toolCallId.trim()
268
+ ? message.toolCallId.trim()
269
+ : (typeof message.id === 'string' && message.id.trim() ? message.id.trim() : '');
270
+ const label = callId
271
+ ? `OpenChamber tool result name=${toolName} call=${callId}`
272
+ : `OpenChamber tool result name=${toolName}`;
273
+ return [{ role: 'user', content: `${label}: ${content}` }];
263
274
  }
264
275
  return [];
265
276
  });
@@ -455,6 +466,7 @@ export async function runContactTurn({
455
466
  createChatCompletion,
456
467
  tools = [],
457
468
  projects = [],
469
+ connectedModels = [],
458
470
  onTextDelta = null,
459
471
  onBubbleDelta = null,
460
472
  globalEventHub = null,
@@ -473,8 +485,13 @@ export async function runContactTurn({
473
485
  const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
474
486
  const systemPrompt = [
475
487
  CONTACT_SYSTEM_PROMPT,
476
- formatPiCodingPrompt({ cwd: runtime?.cwd, skillsPrompt: runtime?.skillsPrompt }),
488
+ formatPiCodingPrompt({
489
+ cwd: runtime?.cwd,
490
+ skillsPrompt: runtime?.skillsPrompt,
491
+ tools: codingTools,
492
+ }),
477
493
  formatRegisteredProjectsPrompt(projects),
494
+ formatConnectedModelsPrompt(connectedModels),
478
495
  formatContactToolsPrompt(contactTools),
479
496
  assistant.defaultPrompt,
480
497
  ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');