@openchambery/web 1.19.13-beta.3 → 1.19.13

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 (50) hide show
  1. package/dist/assets/AssistantView-CKmIS1cS.js +2 -0
  2. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-BdI4PT2H.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-2ewbRZE2.js} +4 -1
  5. package/dist/assets/MarkstreamRendererImpl-DXewO7S_.js +3 -0
  6. package/dist/assets/{MobileDetailNavigation-Bfs4CTUh.js → MobileDetailNavigation-BGaAyuBH.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-DtwVLkBL.js → MobileShareBridge-BAZoyeIP.js} +1 -1
  8. package/dist/assets/MobileSurface-BVb3xNWt.js +2 -0
  9. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-C8ukIdLA.js} +1 -1
  10. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-CkQ61pbr.js} +1 -1
  11. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-CSePZ_V8.js} +1 -1
  12. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-BmSIrZ81.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-2BYaWLo1.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-Dk4gBaOu.js} +1 -1
  15. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-DtPf-h-O.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-BBn8YW82.js → insertionBoundaries-BMhzPFft.js} +1 -1
  17. package/dist/assets/{main-Cd7NwXx5.js → main-BjeOhdcG.js} +4 -4
  18. package/dist/assets/{main-phpzFgMN.js → main-Ci3JmtpW.js} +2 -2
  19. package/dist/assets/{miniChat-D6UYe50H.js → miniChat-BYrjYEXJ.js} +2 -2
  20. package/dist/assets/{mobile-w2Ed9I5O.js → mobile-BVMNnPK2.js} +2 -2
  21. package/dist/assets/{multirun-CYuxJPBX.js → multirun-CmaTeZON.js} +1 -1
  22. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-cw9nrwpr.js} +1 -1
  23. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-COwia8Cs.js} +2 -2
  24. package/dist/assets/{renderMobileApp-BvIE86_O.js → renderMobileApp-CXwwpY1t.js} +4 -4
  25. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-DI8l0VyZ.js} +1 -1
  26. package/dist/assets/{runtimeEndpointReset-lF5S7U4I.js → runtimeEndpointReset-BkAQXmXJ.js} +1 -1
  27. package/dist/assets/{sessionLookup-CNRDupYq.js → sessionLookup-Imc995rW.js} +1 -1
  28. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-BDFUND4v.js} +5 -5
  29. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-C5hZD1co.js} +1 -1
  30. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-e9HjIqGz.js} +1 -1
  31. package/dist/assets/{useMobileNavigationStore-DANrWekj.js → useMobileNavigationStore-CYbDxRTM.js} +1 -1
  32. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-BGy9_AhN.js} +1 -1
  33. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-DTGE0CDs.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/dist/mini-chat.html +2 -2
  36. package/dist/mobile.html +2 -2
  37. package/package.json +1 -1
  38. package/server/lib/assistants/DOCUMENTATION.md +20 -11
  39. package/server/lib/assistants/assign.js +56 -1
  40. package/server/lib/assistants/assign.test.js +60 -0
  41. package/server/lib/assistants/contact-tools.js +212 -1
  42. package/server/lib/assistants/contact-tools.test.js +151 -0
  43. package/server/lib/assistants/harness.js +56 -5
  44. package/server/lib/assistants/harness.test.js +17 -9
  45. package/server/lib/assistants/service.js +514 -16
  46. package/server/lib/assistants/service.test.js +546 -26
  47. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  48. package/dist/assets/MarkdownRenderer-v7n_D6nL.js +0 -2
  49. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  50. package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
@@ -10,7 +10,6 @@ import { contactCardIdentity, parseContactCard, parseContactPart } from './cards
10
10
  import {
11
11
  CONTACT_PAGE_DEFAULT_LIMIT,
12
12
  CONTACT_PAGE_MAX_LIMIT,
13
- CONTACT_SETTLE_TEXT,
14
13
  bumpContactGeneration,
15
14
  clearContactMemory as clearContactMemoryStore,
16
15
  contactHistoryForLlm,
@@ -66,6 +65,7 @@ import {
66
65
  attachmentScopeKey,
67
66
  hasAssignImageParts,
68
67
  resolveAssignDirectory,
68
+ extractAssignSessionModel,
69
69
  resolveAssignWorkerModel,
70
70
  sanitizeAssignFileParts,
71
71
  } from './assign.js';
@@ -128,6 +128,12 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
128
128
  const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
129
129
  /** Bound catalog / project list waits so a contact lane cannot hang forever. */
130
130
  export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
131
+ /** Bound last worker assistant text injected into assigned-session resume prompts. */
132
+ export const ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX = 2_000;
133
+ /** Stable resume turn/message id for assigned-session complete/error continuation. */
134
+ export const assignedSessionResumeMessageID = (assistantID, sessionID, status, updatedAt) => (
135
+ `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`
136
+ );
131
137
  const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
132
138
  let timer = null;
133
139
  try {
@@ -465,37 +471,38 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
465
471
  }
466
472
  return null;
467
473
  };
474
+ // Assigned-session complete/error resumes the contact LLM (no canned settle bubble).
475
+ // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
476
+ const assignedSessionResumeRef = { schedule: null };
468
477
  const reportAssignedSession = (sessionID, status) => {
469
478
  const watches = listWatchesBySession(db, sessionID);
470
479
  if (watches.length === 0) return false;
471
480
  let changed = false;
481
+ const resumes = [];
472
482
  db.exec('BEGIN IMMEDIATE');
473
483
  try {
474
484
  for (const watch of watches) {
475
485
  if (watch.status === status) continue;
476
486
  // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
477
487
  if (status === 'complete' && watch.status === 'error') continue;
488
+ const updatedAt = now();
478
489
  updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
479
490
  upsertContactWatch(db, {
480
491
  assistantID: watch.assistantID,
481
492
  sessionID,
482
493
  directory: watch.directory,
483
494
  status,
484
- updatedAt: now(),
495
+ updatedAt,
485
496
  });
486
- const settleText = CONTACT_SETTLE_TEXT[status];
487
- const settleID = `settle_${watch.assistantID}_${sessionID}_${status}`;
488
- if (settleText && !db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(settleID)) {
489
- insertContactMessage(db, {
490
- messageID: settleID,
497
+ // complete/error: hand result back to contact LLM. question: card only (worker waiting).
498
+ // Never insert oc.settle.* canned transcript bubbles.
499
+ if (status === 'complete' || status === 'error') {
500
+ resumes.push({
491
501
  assistantID: watch.assistantID,
492
- role: 'assistant',
493
- turnID: `settle:${sessionID}`,
494
- bubbleIndex: 0,
495
- createdAt: now(),
496
- ordinal: nextContactOrdinal(db, watch.assistantID),
497
- status: 'complete',
498
- parts: [{ type: 'text', text: settleText }],
502
+ sessionID,
503
+ status,
504
+ directory: watch.directory,
505
+ updatedAt,
499
506
  });
500
507
  }
501
508
  changed = true;
@@ -506,6 +513,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
506
513
  db.exec('ROLLBACK');
507
514
  throw error;
508
515
  }
516
+ // processEvent stays sync; resume runs on the contact turn lane asynchronously.
517
+ if (typeof assignedSessionResumeRef.schedule === 'function') {
518
+ for (const resume of resumes) assignedSessionResumeRef.schedule(resume);
519
+ }
509
520
  return changed;
510
521
  };
511
522
  const sessionStatusType = (session) => {
@@ -1050,6 +1061,42 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1050
1061
  return null;
1051
1062
  }
1052
1063
  };
1064
+ /**
1065
+ * Best-effort previous worker model for a reused sessionID.
1066
+ * session.get → session.model, else newest message info.model. Never throws / never fails assign.
1067
+ */
1068
+ const lookupReusedSessionModel = async ({ sessionID, directory, signal } = {}) => {
1069
+ if (typeof sessionID !== 'string' || !sessionID.trim()) return null;
1070
+ const api = client();
1071
+ const options = signal ? { signal } : undefined;
1072
+ const dir = typeof directory === 'string' && directory.trim() ? directory.trim() : null;
1073
+ try {
1074
+ const got = await api.session.get({
1075
+ sessionID,
1076
+ ...(dir ? { directory: dir } : {}),
1077
+ }, options);
1078
+ if (got?.error) return null;
1079
+ const sessionPayload = got?.data ?? got;
1080
+ let messages = null;
1081
+ const fromSession = extractAssignSessionModel({ session: sessionPayload });
1082
+ if (fromSession) return fromSession;
1083
+ try {
1084
+ if (typeof api.session.messages !== 'function') return null;
1085
+ const listed = await api.session.messages({
1086
+ sessionID,
1087
+ ...(dir ? { directory: dir } : {}),
1088
+ limit: 40,
1089
+ }, options);
1090
+ if (listed?.error) return null;
1091
+ messages = listed?.data ?? listed;
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ return extractAssignSessionModel({ session: sessionPayload, messages });
1096
+ } catch {
1097
+ return null;
1098
+ }
1099
+ };
1053
1100
  const assignWork = async (row, params = {}) => {
1054
1101
  const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
1055
1102
  const signal = params.signal;
@@ -1060,16 +1107,44 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1060
1107
  || (typeof params.model === 'string' && params.model.trim())
1061
1108
  || (params.model && typeof params.model === 'object'),
1062
1109
  );
1063
- const needsCatalog = explicitModel || hasAssignImageParts(fileParts);
1110
+ const reuseSessionID = typeof params.sessionID === 'string' && params.sessionID.trim()
1111
+ ? params.sessionID.trim()
1112
+ : '';
1113
+ // Reused session without explicit model may follow the session's last model (catalog-gated).
1114
+ const followSessionModel = Boolean(reuseSessionID && !explicitModel);
1115
+ const needsCatalog = explicitModel || hasAssignImageParts(fileParts) || followSessionModel;
1064
1116
  let catalog = null;
1117
+ let sessionModel = null;
1065
1118
  if (needsCatalog) {
1066
- catalog = await loadAssignCatalog(signal);
1119
+ if (followSessionModel && !explicitModel && !hasAssignImageParts(fileParts)) {
1120
+ // Session-follow degrades on catalog failure; explicit/image stay fail-closed.
1121
+ try {
1122
+ catalog = await loadAssignCatalog(signal);
1123
+ } catch {
1124
+ catalog = null;
1125
+ }
1126
+ } else {
1127
+ catalog = await loadAssignCatalog(signal);
1128
+ }
1129
+ }
1130
+ if (followSessionModel && catalog) {
1131
+ const directoryHint = typeof params.directory === 'string' && params.directory.trim()
1132
+ ? params.directory.trim()
1133
+ : (typeof params.projectPath === 'string' && params.projectPath.trim()
1134
+ ? params.projectPath.trim()
1135
+ : null);
1136
+ sessionModel = await lookupReusedSessionModel({
1137
+ sessionID: reuseSessionID,
1138
+ directory: directoryHint,
1139
+ signal,
1140
+ });
1067
1141
  }
1068
1142
  const workerModel = resolveAssignWorkerModel({
1069
1143
  providerID: params.providerID,
1070
1144
  modelID: params.modelID,
1071
1145
  model: params.model,
1072
1146
  fallback: { providerID: row.provider_id, modelID: row.model_id },
1147
+ sessionModel,
1073
1148
  catalog,
1074
1149
  });
1075
1150
  let acceptsImages = workerModel.acceptsImages;
@@ -1338,6 +1413,377 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1338
1413
  }),
1339
1414
  };
1340
1415
  };
1416
+ const findAssignedSessionCardTitle = (assistantID, sessionID) => {
1417
+ const rows = db.prepare(
1418
+ 'SELECT p.part_json FROM assistant_contact_part p JOIN assistant_contact_message m ON m.message_id=p.message_id WHERE m.assistant_id=?',
1419
+ ).all(assistantID);
1420
+ for (const row of rows) {
1421
+ let part;
1422
+ try { part = parseContactPart(parse(row.part_json)); } catch { continue; }
1423
+ if (part?.type === 'card' && part.cardType === 'session' && part.sessionID === sessionID) {
1424
+ return typeof part.title === 'string' && part.title.trim() ? part.title.trim() : null;
1425
+ }
1426
+ }
1427
+ return null;
1428
+ };
1429
+ const extractLastWorkerAssistantText = (messages, maxChars = ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX) => {
1430
+ if (!Array.isArray(messages)) return '';
1431
+ const limit = Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX;
1432
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1433
+ const entry = messages[index];
1434
+ const info = entry?.info ?? entry;
1435
+ if (info?.role !== 'assistant') continue;
1436
+ const parts = Array.isArray(entry?.parts)
1437
+ ? entry.parts
1438
+ : (Array.isArray(info?.parts) ? info.parts : []);
1439
+ const chunks = [];
1440
+ for (const part of parts) {
1441
+ if (part?.type === 'text' && typeof part.text === 'string' && part.text.trim()) {
1442
+ chunks.push(part.text.trim());
1443
+ }
1444
+ }
1445
+ if (chunks.length === 0 && typeof info?.content === 'string' && info.content.trim()) {
1446
+ chunks.push(info.content.trim());
1447
+ }
1448
+ if (chunks.length === 0) continue;
1449
+ const joined = chunks.join('\n');
1450
+ return joined.length > limit ? joined.slice(0, limit) : joined;
1451
+ }
1452
+ return '';
1453
+ };
1454
+ const buildAssignedSessionResumeUserText = ({ sessionID, status, title, workerText }) => {
1455
+ const lines = [
1456
+ '[Internal assigned-session resume — not a user message. Do not quote this block.]',
1457
+ `sessionID: ${sessionID}`,
1458
+ `status: ${status}`,
1459
+ ];
1460
+ if (title) lines.push(`cardTitle: ${title}`);
1461
+ if (workerText) {
1462
+ lines.push('lastWorkerAssistantText (bounded):');
1463
+ lines.push(workerText);
1464
+ } else {
1465
+ lines.push('lastWorkerAssistantText: (unavailable)');
1466
+ }
1467
+ lines.push(
1468
+ 'Instructions:',
1469
+ '- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed".',
1470
+ '- If more coding work is needed, call assign_session with the SAME sessionID and omit model args so the prior worker model is reused.',
1471
+ '- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
1472
+ );
1473
+ return lines.join('\n');
1474
+ };
1475
+ /**
1476
+ * After assigned worker complete/error: async contact-lane continuation.
1477
+ * No user transcript row — only internal runContactTurn userText + assistant bubbles/cards.
1478
+ */
1479
+ const scheduleAssignedSessionResume = ({ assistantID, sessionID, status, directory, updatedAt }) => {
1480
+ if (closed) return;
1481
+ if (status !== 'complete' && status !== 'error') return;
1482
+ const row = assistant(assistantID);
1483
+ if (!row || row.tombstone_at) return;
1484
+ const messageID = assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
1485
+ const turnID = messageID;
1486
+ // Idempotent: same watch status already resumed (bubbles or durable error).
1487
+ if (
1488
+ getContactMessage(db, `${messageID}:bubble:1`)
1489
+ || getContactMessage(db, `${messageID}:error`)
1490
+ ) return;
1491
+
1492
+ rememberActiveContactTurn(assistantID, {
1493
+ turnID,
1494
+ messageID,
1495
+ status: 'queued',
1496
+ admittedAt: now(),
1497
+ });
1498
+ // Tip snapshot so list green dots / 3-dot row rehydrate without waiting for lane start.
1499
+ bump();
1500
+ emitContactTurnEvent('openchamber:contact-turn-start', {
1501
+ assistantID,
1502
+ turnID,
1503
+ messageID,
1504
+ });
1505
+ contactTurnSettlement(messageID);
1506
+
1507
+ void inContactTurnLane(assistantID, async () => {
1508
+ if (closed) {
1509
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1510
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1511
+ return;
1512
+ }
1513
+ const live = assistant(assistantID);
1514
+ if (!live || live.tombstone_at) {
1515
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1516
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'not_found' });
1517
+ return;
1518
+ }
1519
+ if (
1520
+ getContactMessage(db, `${messageID}:bubble:1`)
1521
+ || getContactMessage(db, `${messageID}:error`)
1522
+ ) {
1523
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1524
+ resolveContactTurnSettlement(messageID, { status: 'complete', replayed: true });
1525
+ return;
1526
+ }
1527
+ markActiveContactTurnRunning(assistantID, turnID);
1528
+ const assistantSnapshot = output(live);
1529
+ let registeredProjects = [];
1530
+ try {
1531
+ registeredProjects = filterRegisteredProjects(await loadRegisteredProjects());
1532
+ } catch {
1533
+ registeredProjects = [];
1534
+ }
1535
+ let connectedModels = [];
1536
+ try {
1537
+ const catalog = await loadAssignCatalog(undefined);
1538
+ connectedModels = normalizeConnectedModels(catalog?.models);
1539
+ } catch {
1540
+ connectedModels = [];
1541
+ }
1542
+
1543
+ let workerText = '';
1544
+ try {
1545
+ const watchDirectory = nonEmptyString(directory)
1546
+ ? (() => {
1547
+ try { return workspace(directory, assistantID); } catch { return null; }
1548
+ })()
1549
+ : null;
1550
+ const resolvedDirectory = watchDirectory || (() => {
1551
+ try { return effectiveWorkspace(live); } catch { return null; }
1552
+ })();
1553
+ if (resolvedDirectory) {
1554
+ const messagesResult = await client().session.messages({
1555
+ sessionID,
1556
+ directory: resolvedDirectory,
1557
+ limit: 100,
1558
+ });
1559
+ if (!messagesResult?.error) {
1560
+ const rows = Array.isArray(messagesResult?.data)
1561
+ ? messagesResult.data
1562
+ : (Array.isArray(messagesResult) ? messagesResult : []);
1563
+ workerText = extractLastWorkerAssistantText(rows);
1564
+ }
1565
+ }
1566
+ } catch {
1567
+ workerText = '';
1568
+ }
1569
+
1570
+ const title = findAssignedSessionCardTitle(assistantID, sessionID);
1571
+ const userText = buildAssignedSessionResumeUserText({
1572
+ sessionID,
1573
+ status,
1574
+ title,
1575
+ workerText,
1576
+ });
1577
+ const history = contactHistoryForLlm(db, assistantID, {});
1578
+ const assignedCards = [];
1579
+ let contactResetThisTurn = false;
1580
+ let contactHistoryClearedThisTurn = false;
1581
+
1582
+ try {
1583
+ const executionHistory = await materializeContactHistory(
1584
+ db,
1585
+ dataDir,
1586
+ assistantID,
1587
+ history,
1588
+ { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
1589
+ );
1590
+ const tools = createContactTools({
1591
+ assignWork: (params) => assignWork(live, params),
1592
+ createAssistant: (toolInput) => createAssistant(toolInput),
1593
+ scheduleTask: (params) => scheduleWork(live, params),
1594
+ deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
1595
+ clearContactMemory: () => {
1596
+ const result = clearContactMemory(assistantID, {});
1597
+ contactResetThisTurn = true;
1598
+ return result;
1599
+ },
1600
+ resetContact: () => {
1601
+ const result = resetContact(assistantID, {});
1602
+ contactResetThisTurn = true;
1603
+ contactHistoryClearedThisTurn = true;
1604
+ return result;
1605
+ },
1606
+ listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1607
+ listProjects: async () => loadRegisteredProjects(),
1608
+ listSessions: (params) => listSessionsWork(params),
1609
+ readAssistantSettings: (input = {}) => {
1610
+ const targetID = typeof input?.assistantID === 'string' && input.assistantID.trim()
1611
+ ? input.assistantID.trim()
1612
+ : assistantID;
1613
+ return output(editable(targetID));
1614
+ },
1615
+ updateAssistantSettings: async (patch = {}) => {
1616
+ const normalizePrompt = (value) => {
1617
+ if (typeof value !== 'string') fail('validation_error');
1618
+ if (value.length > 200_000) fail('validation_error');
1619
+ return value.trim();
1620
+ };
1621
+ const targetID = typeof patch?.assistantID === 'string' && patch.assistantID.trim()
1622
+ ? patch.assistantID.trim()
1623
+ : assistantID;
1624
+ let lastError = null;
1625
+ for (let attempt = 0; attempt < 2; attempt++) {
1626
+ const current = output(editable(targetID));
1627
+ if (!Object.prototype.hasOwnProperty.call(patch, 'defaultPrompt')) {
1628
+ fail('validation_error');
1629
+ }
1630
+ const nextPrompt = normalizePrompt(patch.defaultPrompt);
1631
+ if (current.defaultPrompt === nextPrompt) {
1632
+ return {
1633
+ updated: false,
1634
+ unchanged: true,
1635
+ defaultPrompt: current.defaultPrompt,
1636
+ assistant: current,
1637
+ };
1638
+ }
1639
+ await Promise.resolve();
1640
+ try {
1641
+ const updated = await updateAssistant(targetID, {
1642
+ expectedRevision: current.revision,
1643
+ defaultPrompt: nextPrompt,
1644
+ });
1645
+ return {
1646
+ updated: true,
1647
+ defaultPrompt: updated.defaultPrompt,
1648
+ assistant: updated,
1649
+ };
1650
+ } catch (error) {
1651
+ lastError = error;
1652
+ if (error?.code !== 'revision_conflict') throw error;
1653
+ }
1654
+ }
1655
+ throw lastError;
1656
+ },
1657
+ currentAssistant: assistantSnapshot,
1658
+ onCard: (card) => assignedCards.push(card),
1659
+ turnFileParts: [],
1660
+ turnAttachmentScope: attachmentScopeKey([]),
1661
+ });
1662
+ const generated = await runContactTurn({
1663
+ assistant: assistantSnapshot,
1664
+ history: executionHistory,
1665
+ userText,
1666
+ userParts: [{ type: 'text', text: userText }],
1667
+ createChatCompletion,
1668
+ tools,
1669
+ projects: registeredProjects,
1670
+ connectedModels,
1671
+ globalEventHub,
1672
+ onBubbleDelta: (bubbleIndex, delta, done) => {
1673
+ emitContactTurnEvent('openchamber:contact-bubble-delta', {
1674
+ assistantID,
1675
+ turnID,
1676
+ bubbleIndex,
1677
+ delta: typeof delta === 'string' ? delta : '',
1678
+ done: Boolean(done),
1679
+ });
1680
+ },
1681
+ });
1682
+ if (closed) {
1683
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1684
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1685
+ return;
1686
+ }
1687
+ const resetThisTurn = contactResetThisTurn || generated?.reset === true;
1688
+ const historyClearedThisTurn = contactHistoryClearedThisTurn
1689
+ || contactTurnClearedChatHistory(generated?.messages)
1690
+ || generated?.historyCleared === true;
1691
+ const preferredConfirm = historyClearedThisTurn
1692
+ ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
1693
+ : NEW_CONVERSATION_CONFIRM_BUBBLE;
1694
+ const bubbles = resetThisTurn
1695
+ ? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
1696
+ : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
1697
+ const cards = resetThisTurn
1698
+ ? []
1699
+ : [
1700
+ ...assignedCards,
1701
+ ...(Array.isArray(generated?.cards) ? generated.cards : []),
1702
+ ].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
1703
+ if (bubbles.length === 0 && cards.length === 0) {
1704
+ const detail = 'Assistant returned no text';
1705
+ try {
1706
+ persistContactTurnFailure(assistantID, {
1707
+ userMessageID: messageID,
1708
+ turnID,
1709
+ error: detail,
1710
+ });
1711
+ settleActiveContactTurn(assistantID, turnID);
1712
+ } catch {
1713
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1714
+ }
1715
+ emitContactTurnEvent('openchamber:contact-turn-end', {
1716
+ assistantID,
1717
+ turnID,
1718
+ status: 'error',
1719
+ error: detail,
1720
+ });
1721
+ notifyContactTurn({
1722
+ assistantID,
1723
+ name: assistantSnapshot.name,
1724
+ turnID,
1725
+ status: 'error',
1726
+ body: detail,
1727
+ });
1728
+ resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
1729
+ return;
1730
+ }
1731
+ // No user row for resume — only assistant bubbles/cards (userMessageID is id prefix).
1732
+ persistContactAssistantReply(assistantID, {
1733
+ userMessageID: messageID,
1734
+ bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
1735
+ cards,
1736
+ turnID,
1737
+ });
1738
+ settleActiveContactTurn(assistantID, turnID);
1739
+ emitContactTurnEvent('openchamber:contact-turn-end', {
1740
+ assistantID,
1741
+ turnID,
1742
+ status: 'complete',
1743
+ });
1744
+ const spoken = bubbles.filter((text) => typeof text === 'string' && text.trim() && !text.startsWith('oc.settle.'));
1745
+ notifyContactTurn({
1746
+ assistantID,
1747
+ name: assistantSnapshot.name,
1748
+ turnID,
1749
+ status: 'complete',
1750
+ body: spoken.join('\n') || cards[0]?.title || '',
1751
+ });
1752
+ resolveContactTurnSettlement(messageID, { status: 'complete' });
1753
+ } catch (error) {
1754
+ const detail = typeof error?.message === 'string' && error.message.trim()
1755
+ ? error.message.trim()
1756
+ : (error?.code || 'upstream_error');
1757
+ const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
1758
+ try {
1759
+ persistContactTurnFailure(assistantID, {
1760
+ userMessageID: messageID,
1761
+ turnID,
1762
+ error: detail,
1763
+ });
1764
+ settleActiveContactTurn(assistantID, turnID);
1765
+ } catch {
1766
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1767
+ }
1768
+ emitContactTurnEvent('openchamber:contact-turn-end', {
1769
+ assistantID,
1770
+ turnID,
1771
+ status: 'error',
1772
+ error: detail,
1773
+ ...(statusCode !== detail ? { code: statusCode } : {}),
1774
+ });
1775
+ notifyContactTurn({
1776
+ assistantID,
1777
+ name: assistantSnapshot.name,
1778
+ turnID,
1779
+ status: 'error',
1780
+ body: detail,
1781
+ });
1782
+ resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
1783
+ }
1784
+ });
1785
+ };
1786
+ assignedSessionResumeRef.schedule = scheduleAssignedSessionResume;
1341
1787
  /**
1342
1788
  * Contact composer send: validate → admit user (202) → async turn.
1343
1789
  * Does not await the LLM. Live bubble tokens use openchamber:contact-bubble-delta
@@ -1479,6 +1925,58 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1479
1925
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1480
1926
  listProjects: async () => loadRegisteredProjects(),
1481
1927
  listSessions: (params) => listSessionsWork(params),
1928
+ // Live DB read — not the turn-start assistantSnapshot.
1929
+ // Optional assistantID targets another live row; omitted → this contact.
1930
+ readAssistantSettings: (input = {}) => {
1931
+ const assistantID = typeof input?.assistantID === 'string' && input.assistantID.trim()
1932
+ ? input.assistantID.trim()
1933
+ : row.assistant_id;
1934
+ return output(editable(assistantID));
1935
+ },
1936
+ // Persist defaultPrompt via updateAssistant CAS; one revision_conflict retry.
1937
+ updateAssistantSettings: async (patch = {}) => {
1938
+ const normalizePrompt = (value) => {
1939
+ if (typeof value !== 'string') fail('validation_error');
1940
+ if (value.length > 200_000) fail('validation_error');
1941
+ return value.trim();
1942
+ };
1943
+ const assistantID = typeof patch?.assistantID === 'string' && patch.assistantID.trim()
1944
+ ? patch.assistantID.trim()
1945
+ : row.assistant_id;
1946
+ let lastError = null;
1947
+ for (let attempt = 0; attempt < 2; attempt++) {
1948
+ const current = output(editable(assistantID));
1949
+ if (!Object.prototype.hasOwnProperty.call(patch, 'defaultPrompt')) {
1950
+ fail('validation_error');
1951
+ }
1952
+ const nextPrompt = normalizePrompt(patch.defaultPrompt);
1953
+ if (current.defaultPrompt === nextPrompt) {
1954
+ return {
1955
+ updated: false,
1956
+ unchanged: true,
1957
+ defaultPrompt: current.defaultPrompt,
1958
+ assistant: current,
1959
+ };
1960
+ }
1961
+ // Yield so a concurrent UI PATCH can land before CAS (and tests can inject).
1962
+ await Promise.resolve();
1963
+ try {
1964
+ const updated = await updateAssistant(assistantID, {
1965
+ expectedRevision: current.revision,
1966
+ defaultPrompt: nextPrompt,
1967
+ });
1968
+ return {
1969
+ updated: true,
1970
+ defaultPrompt: updated.defaultPrompt,
1971
+ assistant: updated,
1972
+ };
1973
+ } catch (error) {
1974
+ lastError = error;
1975
+ if (error?.code !== 'revision_conflict') throw error;
1976
+ }
1977
+ }
1978
+ throw lastError;
1979
+ },
1482
1980
  currentAssistant: assistantSnapshot,
1483
1981
  onCard: (card) => assignedCards.push(card),
1484
1982
  turnFileParts,