@openchambery/web 1.19.14 → 1.19.15-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/assets/{AssistantView-Dncbu_Z2.js → AssistantView-D5rEZ-Ss.js} +2 -2
  2. package/dist/assets/{DiagramView-CovEl1BY.js → DiagramView-DaB9Sb7S.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-emLeoNjI.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-CCfHY-86.js → MarkdownRendererImpl-BWCAPlqn.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-y6QgIUZA.js → MarkstreamRendererImpl-BD_NFKUv.js} +1 -1
  6. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  7. package/dist/assets/{MobileShareBridge-B4S7BaUj.js → MobileShareBridge-Bk2fdkJk.js} +1 -1
  8. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  9. package/dist/assets/{MultiRunWindow-BeHa-CG6.js → MultiRunWindow-rs49_K1E.js} +1 -1
  10. package/dist/assets/{SettingsView-Dn7odtQz.js → SettingsView-b8LUjoad.js} +2 -2
  11. package/dist/assets/{SettingsWindow-C0tWPS5f.js → SettingsWindow-D8JFlPdA.js} +1 -1
  12. package/dist/assets/{TerminalView-BnJgC7Qo.js → TerminalView-DCd-Godi.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-D9S-X8hM.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DBrvaJnq.js → appThemeRegistry-BWtCaTMe.js} +1 -1
  15. package/dist/assets/{fileMentionTouchSelection-Dc004xOh.js → fileMentionTouchSelection-DKYkoypI.js} +1 -1
  16. package/dist/assets/{gitApi-Cm3JLQqh.js → gitApi-D4PyYgLZ.js} +1 -1
  17. package/dist/assets/{insertionBoundaries-CTWYFsbH.js → insertionBoundaries-Bnnx03Wj.js} +1 -1
  18. package/dist/assets/{main-CSqu3MB4.js → main-BRiQTNFX.js} +4 -4
  19. package/dist/assets/{main-DwMprEyr.js → main-CnY0LWNc.js} +2 -2
  20. package/dist/assets/{miniChat-C8bzY_Ye.js → miniChat-B0pw_mUs.js} +2 -2
  21. package/dist/assets/{mobile-Oy-SRKkr.js → mobile-BKHmxwXz.js} +2 -2
  22. package/dist/assets/{multirun-ClcuaiKU.js → multirun-BQKqVfFT.js} +1 -1
  23. package/dist/assets/{projectMeta-F93wE2Ye.js → projectMeta-BvG8sdtC.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-rOOhMVlQ.js → renderElectronMiniChatApp-E5a0yy7E.js} +2 -2
  25. package/dist/assets/{renderMobileApp-BP_hGwHP.js → renderMobileApp-DT0L4Ah6.js} +4 -4
  26. package/dist/assets/{runtimeConfig-iJ2mTBVN.js → runtimeConfig-utWzrDwa.js} +1 -1
  27. package/dist/assets/{runtimeEndpointReset-ISnhAS3q.js → runtimeEndpointReset-DE6lwHyg.js} +1 -1
  28. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  29. package/dist/assets/{sessionLookup-DQQ9-DuP.js → sessionLookup-12zVOR0J.js} +1 -1
  30. package/dist/assets/src-CFUi4riW.css +2 -0
  31. package/dist/assets/{useAppFontEffects--IcU7jyv.js → useAppFontEffects-BhYuP4L2.js} +5 -5
  32. package/dist/assets/{useDesktopWindowControlsLayout-BqJ5R_T2.js → useDesktopWindowControlsLayout-DPNMRkrI.js} +1 -1
  33. package/dist/assets/{useEffectiveDirectory-ChWXV8xM.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  34. package/dist/assets/{useMobileNavigationStore-DQkgxuzg.js → useMobileNavigationStore-BYdpuwL7.js} +1 -1
  35. package/dist/assets/{useSessionAutoCleanup-DWWAvQQ0.js → useSessionAutoCleanup-CADZCvZL.js} +1 -1
  36. package/dist/assets/{useWorkerHighlightedLines-D2oQszsk.js → useWorkerHighlightedLines-CuG88Lsj.js} +1 -1
  37. package/dist/index.html +3 -3
  38. package/dist/mini-chat.html +3 -3
  39. package/dist/mobile.html +3 -3
  40. package/package.json +1 -1
  41. package/server/lib/assistants/DOCUMENTATION.md +33 -12
  42. package/server/lib/assistants/contact-store.js +25 -1
  43. package/server/lib/assistants/contact-store.test.js +39 -5
  44. package/server/lib/assistants/contact-tools.js +3 -3
  45. package/server/lib/assistants/harness.js +136 -101
  46. package/server/lib/assistants/harness.test.js +235 -8
  47. package/server/lib/assistants/pi-tools.js +2 -0
  48. package/server/lib/assistants/pi-tools.test.js +8 -0
  49. package/server/lib/assistants/service.js +313 -74
  50. package/server/lib/assistants/service.test.js +555 -18
  51. package/server/lib/llm/DOCUMENTATION.md +9 -4
  52. package/server/lib/llm/generate.js +45 -6
  53. package/server/lib/llm/generate.test.js +52 -0
  54. package/dist/assets/MarkdownRenderer-CZqok-Cf.js +0 -2
  55. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +0 -1
  56. package/dist/assets/MobileSurface-xL5Jby4U.js +0 -2
  57. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +0 -2
  58. package/dist/assets/src-DYeppLfP.css +0 -2
@@ -17,6 +17,7 @@ import {
17
17
  createActiveContactTurn,
18
18
  deleteContactMessages,
19
19
  deleteContactReadState,
20
+ deleteContactTurnAssistantOutputs,
20
21
  ensureContactSchema,
21
22
  contactPartsFingerprint,
22
23
  getContactMessage,
@@ -140,10 +141,11 @@ const promptAdmitted = (result) => !result?.error && (result?.response?.status =
140
141
  export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
141
142
  /** Bound last worker assistant text injected into assigned-session resume prompts. */
142
143
  export const ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX = 2_000;
143
- /** Stable resume turn/message id for assigned-session complete/error continuation. */
144
+ /** Stable resume turn/message id for assigned-session complete/error/cancelled continuation. */
144
145
  export const assignedSessionResumeMessageID = (assistantID, sessionID, status, updatedAt) => (
145
146
  `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`
146
147
  );
148
+ const assignedSessionResumeStatus = (status) => status === 'complete' || status === 'error' || status === 'cancelled';
147
149
  const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
148
150
  let timer = null;
149
151
  try {
@@ -492,13 +494,35 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
492
494
  // Last UI locale seen from a client per assistant. Background notifications
493
495
  // (assigned-session settle) have no request locale, so they recall this value.
494
496
  const contactTurnLanguages = new Map();
495
- const cancelAssignedResumes = ({ sessionID, assistantID }) => {
497
+ const cancelAssignedResumes = ({ sessionID, assistantID, statuses } = {}) => {
496
498
  for (const resume of assignedResumes.values()) {
497
499
  if (sessionID && resume.sessionID !== sessionID) continue;
498
500
  if (assistantID && resume.assistantID !== assistantID) continue;
501
+ if (statuses && !statuses.includes(resume.status)) continue;
499
502
  resume.controller.abort();
500
503
  }
501
504
  };
505
+ // Abort-request lifecycle only (stop_session). Concurrent same-session aborts share one promise.
506
+ // Observed SSE terminals are stashed until abort settles so resume=true cannot beat resume=false.
507
+ const sessionAbortInflight = new Map();
508
+ const abortTerminalRank = (status) => {
509
+ if (status === 'cancelled') return 3;
510
+ if (status === 'error') return 2;
511
+ if (status === 'complete') return 1;
512
+ return 0;
513
+ };
514
+ const stashSessionAbortTerminal = (entry, status, resume) => {
515
+ if (!entry || typeof status !== 'string' || !status) return;
516
+ const nextRank = abortTerminalRank(status);
517
+ if (nextRank <= 0) return;
518
+ const prev = entry.observed;
519
+ const prevRank = prev ? abortTerminalRank(prev.status) : -1;
520
+ // cancelled wins over complete/error; error wins over complete; equal rank keeps latest.
521
+ if (nextRank > prevRank || (nextRank === prevRank && (status === 'cancelled' || nextRank > 0))) {
522
+ entry.observed = { status, resume: resume !== false };
523
+ if (status === 'cancelled') entry.observed.resume = resume !== false;
524
+ }
525
+ };
502
526
  const settleStatusFromEvent = (payload, properties) => {
503
527
  if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
504
528
  if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
@@ -510,11 +534,20 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
510
534
  }
511
535
  return null;
512
536
  };
513
- // Assigned-session complete/error resumes the contact LLM (no canned settle bubble).
537
+ // Assigned-session complete/error/cancelled resumes the contact LLM (no canned settle bubble).
514
538
  // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
515
539
  const assignedSessionResumeRef = { schedule: null };
516
- const reportAssignedSession = (sessionID, status) => {
517
- if (status === 'cancelled') cancelAssignedResumes({ sessionID });
540
+ const reportAssignedSession = (sessionID, status, resume = true) => {
541
+ const inflight = sessionAbortInflight.get(sessionID);
542
+ if (inflight) {
543
+ // Defer watch terminal + continuation until stop_session abort settles.
544
+ stashSessionAbortTerminal(inflight, status, resume);
545
+ return false;
546
+ }
547
+ // Drop complete/error notifies for this worker. Do not abort a cancelled
548
+ // interrupt notify — duplicate cancelled reports (boot+timer, error+message.updated)
549
+ // must still reach the contact LLM.
550
+ if (status === 'cancelled') cancelAssignedResumes({ sessionID, statuses: ['complete', 'error'] });
518
551
  const watches = listWatchesBySession(db, sessionID);
519
552
  if (watches.length === 0) return false;
520
553
  let changed = false;
@@ -535,9 +568,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
535
568
  updatedAt,
536
569
  resumeAllowed: watch.resumeAllowed,
537
570
  });
538
- // complete/error: hand result back to contact LLM. question: card only (worker waiting).
571
+ // complete/error/user-interrupt cancelled: hand result back to contact LLM.
572
+ // question: card only (worker waiting). stop_session passes resume=false.
539
573
  // Never insert oc.settle.* canned transcript bubbles.
540
- if (watch.resumeAllowed && (status === 'complete' || status === 'error')) {
574
+ if (resume && watch.resumeAllowed && assignedSessionResumeStatus(status)) {
541
575
  resumes.push({
542
576
  assistantID: watch.assistantID,
543
577
  sessionID,
@@ -878,7 +912,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
878
912
  if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
879
913
  return parts;
880
914
  };
881
- /** Admit the user row only — HTTP 202 returns here; assistant bubbles persist later. */
915
+ /** Admit the user row only — HTTP 202 returns here; assistant bubbles persist as they publish. */
882
916
  const admitContactUser = (assistantID, { userMessageID, userText, userParts, turnID }) => {
883
917
  db.exec('BEGIN IMMEDIATE');
884
918
  try {
@@ -925,13 +959,135 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
925
959
  throw writeError;
926
960
  }
927
961
  };
928
- /** Persist assistant bubbles/cards for an already-admitted user turn (no user row). */
929
- const persistContactAssistantReply = (assistantID, { userMessageID, bubbles, cards = [], turnID }) => {
962
+ const contactBubbleMessageID = (userMessageID, bubbleIndex) => `${userMessageID}:bubble:${bubbleIndex + 1}`;
963
+ const contactCardMessageID = (userMessageID, index) => `${userMessageID}:card:${index + 1}`;
964
+
965
+ const persistPublishedContactBubble = (assistantID, { userMessageID, turnID, bubbleIndex, text }) => {
966
+ const trimmed = typeof text === 'string' ? text.trim() : '';
967
+ if (!trimmed || trimmed.startsWith('oc.settle.')) return false;
968
+ const bubbleID = contactBubbleMessageID(userMessageID, bubbleIndex);
969
+ if (getContactMessage(db, bubbleID)) return false;
970
+ db.exec('BEGIN IMMEDIATE');
971
+ try {
972
+ if (getContactMessage(db, bubbleID)) {
973
+ db.exec('COMMIT');
974
+ return false;
975
+ }
976
+ insertContactMessage(db, {
977
+ messageID: bubbleID,
978
+ assistantID,
979
+ role: 'assistant',
980
+ turnID,
981
+ bubbleIndex,
982
+ createdAt: now(),
983
+ ordinal: nextContactOrdinal(db, assistantID),
984
+ status: 'complete',
985
+ parts: [{ type: 'text', text: trimmed }],
986
+ });
987
+ bump();
988
+ db.exec('COMMIT');
989
+ return true;
990
+ } catch (error) {
991
+ db.exec('ROLLBACK');
992
+ throw error;
993
+ }
994
+ };
995
+
996
+ const persistPublishedContactCard = (assistantID, { userMessageID, turnID, index, card: cardInput }) => {
997
+ const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
998
+ if (!card) return false;
999
+ const cardID = contactCardMessageID(userMessageID, index);
1000
+ if (getContactMessage(db, cardID)) return false;
1001
+ db.exec('BEGIN IMMEDIATE');
1002
+ try {
1003
+ if (getContactMessage(db, cardID)) {
1004
+ db.exec('COMMIT');
1005
+ return false;
1006
+ }
1007
+ insertContactMessage(db, {
1008
+ messageID: cardID,
1009
+ assistantID,
1010
+ role: 'assistant',
1011
+ turnID,
1012
+ bubbleIndex: 0,
1013
+ createdAt: now(),
1014
+ ordinal: nextContactOrdinal(db, assistantID),
1015
+ status: 'complete',
1016
+ parts: [card],
1017
+ });
1018
+ if (card.cardType === 'session' && card.sessionID) {
1019
+ upsertContactWatch(db, {
1020
+ assistantID,
1021
+ sessionID: card.sessionID,
1022
+ directory: card.directory,
1023
+ status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1024
+ updatedAt: now(),
1025
+ });
1026
+ }
1027
+ bump();
1028
+ db.exec('COMMIT');
1029
+ return true;
1030
+ } catch (error) {
1031
+ db.exec('ROLLBACK');
1032
+ throw error;
1033
+ }
1034
+ };
1035
+
1036
+ const publishContactBubbleDelta = ({
1037
+ assistantID, turnID, userMessageID, spokenByIndex, bubbleIndex, delta, done, signal,
1038
+ }) => {
1039
+ if (signal?.aborted) return;
1040
+ const chunk = typeof delta === 'string' ? delta : '';
1041
+ if (chunk) spokenByIndex.set(bubbleIndex, `${spokenByIndex.get(bubbleIndex) || ''}${chunk}`);
1042
+ emitContactTurnEvent('openchamber:contact-bubble-delta', {
1043
+ assistantID,
1044
+ turnID,
1045
+ bubbleIndex,
1046
+ delta: chunk,
1047
+ done: Boolean(done),
1048
+ });
1049
+ if (!done) return;
1050
+ try {
1051
+ persistPublishedContactBubble(assistantID, {
1052
+ userMessageID,
1053
+ turnID,
1054
+ bubbleIndex,
1055
+ text: spokenByIndex.get(bubbleIndex) || '',
1056
+ });
1057
+ } catch {
1058
+ // Publishing a spoken bubble must not abort the live turn.
1059
+ }
1060
+ };
1061
+
1062
+ const rememberPublishedContactCard = (assistantID, { userMessageID, turnID, assignedCards, card }) => {
1063
+ assignedCards.push(card);
1064
+ try {
1065
+ persistPublishedContactCard(assistantID, {
1066
+ userMessageID,
1067
+ turnID,
1068
+ index: assignedCards.length - 1,
1069
+ card,
1070
+ });
1071
+ } catch {
1072
+ // Publishing a card must not abort the live turn.
1073
+ }
1074
+ };
1075
+
1076
+ /** Persist remaining assistant bubbles/cards for an already-admitted user turn (no user row). */
1077
+ const persistContactAssistantReply = (assistantID, {
1078
+ userMessageID, bubbles, cards = [], turnID, reset = false,
1079
+ }) => {
930
1080
  db.exec('BEGIN IMMEDIATE');
931
1081
  try {
1082
+ // Successful new_conversation / clear_chat_history: drop this turn's pre-reset
1083
+ // spoken/card rows so the unique confirm can own bubble:1 (same id is not skipped).
1084
+ if (reset) {
1085
+ deleteContactTurnAssistantOutputs(db, assistantID, turnID);
1086
+ }
932
1087
  let ordinal = nextContactOrdinal(db, assistantID);
933
1088
  bubbles.forEach((text, index) => {
934
- const bubbleID = `${userMessageID}:bubble:${index + 1}`;
1089
+ const bubbleID = contactBubbleMessageID(userMessageID, index);
1090
+ if (getContactMessage(db, bubbleID)) return;
935
1091
  insertContactMessage(db, {
936
1092
  messageID: bubbleID,
937
1093
  assistantID,
@@ -948,8 +1104,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
948
1104
  cards.forEach((cardInput, index) => {
949
1105
  const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
950
1106
  if (!card) return;
1107
+ const cardID = contactCardMessageID(userMessageID, index);
1108
+ if (getContactMessage(db, cardID)) return;
951
1109
  insertContactMessage(db, {
952
- messageID: `${userMessageID}:card:${index + 1}`,
1110
+ messageID: cardID,
953
1111
  assistantID,
954
1112
  role: 'assistant',
955
1113
  turnID,
@@ -1536,42 +1694,90 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1536
1694
  });
1537
1695
  const signal = params.signal;
1538
1696
  signal?.throwIfAborted();
1539
- const sdkOptions = signal ? { signal } : undefined;
1540
1697
  if (typeof client().session.abort !== 'function') {
1541
1698
  throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
1542
1699
  }
1543
- let result;
1544
- try {
1545
- result = await client().session.abort({
1546
- sessionID: resolved.sessionID,
1547
- directory: resolved.directory,
1548
- }, sdkOptions);
1549
- } catch (error) {
1550
- throw new AssignError(
1551
- ASSIGN_CODES.UPSTREAM,
1552
- typeof error?.message === 'string' && error.message.trim()
1553
- ? error.message.trim()
1554
- : 'Failed to abort that coding session.',
1555
- );
1556
- }
1557
- if (isMissing(result)) {
1558
- throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${resolved.sessionID} was found.`);
1700
+ const targetID = resolved.sessionID;
1701
+ // Share one abort lifecycle per worker session (register before await abort).
1702
+ const existing = sessionAbortInflight.get(targetID);
1703
+ if (existing?.promise) {
1704
+ const shared = await existing.promise;
1705
+ if (shared?.ok) {
1706
+ return {
1707
+ sessionID: targetID,
1708
+ directory: resolved.directory,
1709
+ title: resolved.title,
1710
+ aborted: true,
1711
+ };
1712
+ }
1713
+ throw shared?.error || new AssignError(ASSIGN_CODES.UPSTREAM, 'Failed to abort that coding session.');
1559
1714
  }
1560
- if (result?.error || result?.data === false) {
1561
- throw new AssignError(
1562
- ASSIGN_CODES.UPSTREAM,
1563
- typeof result.error?.message === 'string' && result.error.message.trim()
1564
- ? result.error.message.trim()
1565
- : 'Failed to abort that coding session.',
1566
- );
1715
+ const entry = { sessionID: targetID, observed: null, promise: null };
1716
+ sessionAbortInflight.set(targetID, entry);
1717
+ const failAbort = (code, message) => new AssignError(
1718
+ code,
1719
+ typeof message === 'string' && message.trim() ? message.trim() : 'Failed to abort that coding session.',
1720
+ );
1721
+ entry.promise = (async () => {
1722
+ const sdkOptions = signal ? { signal } : undefined;
1723
+ let result;
1724
+ try {
1725
+ signal?.throwIfAborted();
1726
+ result = await client().session.abort({
1727
+ sessionID: targetID,
1728
+ directory: resolved.directory,
1729
+ }, sdkOptions);
1730
+ } catch (error) {
1731
+ const observed = entry.observed;
1732
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1733
+ if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
1734
+ const wrapped = error?.name === 'AbortError' || signal?.aborted
1735
+ ? error
1736
+ : failAbort(
1737
+ ASSIGN_CODES.UPSTREAM,
1738
+ typeof error?.message === 'string' ? error.message : '',
1739
+ );
1740
+ return { ok: false, error: wrapped };
1741
+ }
1742
+ if (isMissing(result)) {
1743
+ const observed = entry.observed;
1744
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1745
+ if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
1746
+ return { ok: false, error: failAbort(ASSIGN_CODES.NOT_FOUND, `No coding session ${targetID} was found.`) };
1747
+ }
1748
+ if (result?.error || result?.data === false) {
1749
+ const observed = entry.observed;
1750
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1751
+ if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
1752
+ return {
1753
+ ok: false,
1754
+ error: failAbort(
1755
+ ASSIGN_CODES.UPSTREAM,
1756
+ typeof result.error?.message === 'string' ? result.error.message : '',
1757
+ ),
1758
+ };
1759
+ }
1760
+ // Success (including silent abort with no SSE): release map first so reporter applies.
1761
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1762
+ cancelAssignedResumes({ sessionID: targetID });
1763
+ // Assistant already owns this user turn; do not schedule a second cancelled notify.
1764
+ reportAssignedSession(targetID, 'cancelled', false);
1765
+ return { ok: true };
1766
+ })();
1767
+ try {
1768
+ const outcome = await entry.promise;
1769
+ if (outcome?.ok) {
1770
+ return {
1771
+ sessionID: targetID,
1772
+ directory: resolved.directory,
1773
+ title: resolved.title,
1774
+ aborted: true,
1775
+ };
1776
+ }
1777
+ throw outcome?.error || failAbort(ASSIGN_CODES.UPSTREAM, '');
1778
+ } finally {
1779
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1567
1780
  }
1568
- reportAssignedSession(resolved.sessionID, 'cancelled');
1569
- return {
1570
- sessionID: resolved.sessionID,
1571
- directory: resolved.directory,
1572
- title: resolved.title,
1573
- aborted: true,
1574
- };
1575
1781
  };
1576
1782
  // Resolve scope from the authoritative session, never model-supplied directories.
1577
1783
  const mutateSessionWork = async (operation, params = {}) => {
@@ -1877,6 +2083,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1877
2083
  `sessionID: ${sessionID}`,
1878
2084
  `status: ${status}`,
1879
2085
  ];
2086
+ if (status === 'cancelled') {
2087
+ lines.push(
2088
+ 'reason: user_interrupted',
2089
+ 'The user manually interrupted this assigned worker session (OpenCode session.abort / MessageAbortedError). This is not a model failure and not an assistant-initiated stop_session.',
2090
+ );
2091
+ }
1880
2092
  if (title) lines.push(`cardTitle: ${title}`);
1881
2093
  if (workerText) {
1882
2094
  lines.push('lastWorkerAssistantText (bounded):');
@@ -1886,20 +2098,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1886
2098
  }
1887
2099
  lines.push(
1888
2100
  'Instructions:',
1889
- '- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed".',
2101
+ '- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed" / "会话已取消" / "Session cancelled".',
1890
2102
  '- This is a read-only result notification, never authorization to create, restart, continue, steer, or otherwise mutate any session or file.',
1891
2103
  '- Respect the latest user instructions in history, including stop/cancel. If work remains, report it and wait for an explicit user request.',
1892
- '- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
1893
2104
  );
2105
+ if (status === 'cancelled') {
2106
+ lines.push(
2107
+ '- The user interrupted this worker themselves. In the user\'s language, acknowledge that interruption and decide the next spoken step (usually stop here and wait). Do not restart the worker unless history already asked you to.',
2108
+ );
2109
+ } else {
2110
+ lines.push(
2111
+ '- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
2112
+ );
2113
+ }
1894
2114
  return lines.join('\n');
1895
2115
  };
1896
2116
  /**
1897
- * After assigned worker complete/error: async contact-lane continuation.
2117
+ * After assigned worker complete/error/cancelled: async contact-lane continuation.
1898
2118
  * No user transcript row — only internal runContactTurn userText + assistant bubbles/cards.
1899
2119
  */
1900
2120
  const scheduleAssignedSessionResume = ({ assistantID, sessionID, status, directory, updatedAt }) => {
1901
2121
  if (closed) return;
1902
- if (status !== 'complete' && status !== 'error') return;
2122
+ if (!assignedSessionResumeStatus(status)) return;
1903
2123
  const row = assistant(assistantID);
1904
2124
  if (!row || row.tombstone_at) return;
1905
2125
  const messageID = assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
@@ -1912,7 +2132,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1912
2132
 
1913
2133
  if (assignedResumes.has(messageID)) return;
1914
2134
  const controller = new AbortController();
1915
- assignedResumes.set(messageID, { assistantID, sessionID, controller });
2135
+ assignedResumes.set(messageID, { assistantID, sessionID, status, controller });
1916
2136
  const finishCancelled = () => {
1917
2137
  settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1918
2138
  emitContactTurnEvent('openchamber:contact-turn-end', { assistantID, turnID, status: 'cancelled' });
@@ -1985,7 +2205,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1985
2205
  const rows = Array.isArray(messagesResult?.data)
1986
2206
  ? messagesResult.data
1987
2207
  : (Array.isArray(messagesResult) ? messagesResult : []);
1988
- if (isUserAbort(lastAssistantInfo(rows)?.error)) {
2208
+ // complete/error that discovers a user abort becomes cancelled + notify.
2209
+ // A cancelled resume must still run with reason: user_interrupted.
2210
+ if (status !== 'cancelled' && isUserAbort(lastAssistantInfo(rows)?.error)) {
1989
2211
  reportAssignedSession(sessionID, 'cancelled');
1990
2212
  finishCancelled();
1991
2213
  return;
@@ -2006,9 +2228,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2006
2228
  workerText,
2007
2229
  });
2008
2230
  const history = contactHistoryForLlm(db, assistantID, {});
2009
- const assignedCards = [];
2010
- let contactResetThisTurn = false;
2011
- let contactHistoryClearedThisTurn = false;
2231
+ const assignedCards = [];
2232
+ const spokenByIndex = new Map();
2233
+ let contactResetThisTurn = false;
2234
+ let contactHistoryClearedThisTurn = false;
2235
+ const markContactReset = (historyCleared = false) => {
2236
+ contactResetThisTurn = true;
2237
+ if (historyCleared) contactHistoryClearedThisTurn = true;
2238
+ spokenByIndex.clear();
2239
+ assignedCards.length = 0;
2240
+ };
2012
2241
 
2013
2242
  try {
2014
2243
  const executionHistory = await materializeContactHistory(
@@ -2030,13 +2259,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2030
2259
  deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
2031
2260
  clearContactMemory: () => {
2032
2261
  const result = clearContactMemory(assistantID, {});
2033
- contactResetThisTurn = true;
2262
+ markContactReset(false);
2034
2263
  return result;
2035
2264
  },
2036
2265
  resetContact: () => {
2037
2266
  const result = resetContact(assistantID, {});
2038
- contactResetThisTurn = true;
2039
- contactHistoryClearedThisTurn = true;
2267
+ markContactReset(true);
2040
2268
  return result;
2041
2269
  },
2042
2270
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
@@ -2092,7 +2320,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2092
2320
  throw lastError;
2093
2321
  },
2094
2322
  currentAssistant: assistantSnapshot,
2095
- onCard: (card) => assignedCards.push(card),
2323
+ onCard: (card) => {
2324
+ if (contactResetThisTurn) return;
2325
+ rememberPublishedContactCard(assistantID, {
2326
+ userMessageID: messageID, turnID, assignedCards, card,
2327
+ });
2328
+ },
2096
2329
  turnFileParts: [],
2097
2330
  turnAttachmentScope: attachmentScopeKey([]),
2098
2331
  });
@@ -2111,13 +2344,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2111
2344
  ...modelContext,
2112
2345
  globalEventHub,
2113
2346
  onBubbleDelta: (bubbleIndex, delta, done) => {
2114
- if (controller.signal.aborted) return;
2115
- emitContactTurnEvent('openchamber:contact-bubble-delta', {
2116
- assistantID,
2117
- turnID,
2118
- bubbleIndex,
2119
- delta: typeof delta === 'string' ? delta : '',
2120
- done: Boolean(done),
2347
+ if (contactResetThisTurn) return;
2348
+ publishContactBubbleDelta({
2349
+ assistantID, turnID, userMessageID: messageID, spokenByIndex, bubbleIndex, delta, done, signal: controller.signal,
2121
2350
  });
2122
2351
  },
2123
2352
  });
@@ -2177,6 +2406,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2177
2406
  bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
2178
2407
  cards,
2179
2408
  turnID,
2409
+ reset: resetThisTurn,
2180
2410
  });
2181
2411
  settleActiveContactTurn(assistantID, turnID);
2182
2412
  emitContactTurnEvent('openchamber:contact-turn-end', {
@@ -2324,8 +2554,15 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2324
2554
  beforeOrdinal,
2325
2555
  });
2326
2556
  const assignedCards = [];
2557
+ const spokenByIndex = new Map();
2327
2558
  let contactResetThisTurn = false;
2328
2559
  let contactHistoryClearedThisTurn = false;
2560
+ const markContactReset = (historyCleared = false) => {
2561
+ contactResetThisTurn = true;
2562
+ if (historyCleared) contactHistoryClearedThisTurn = true;
2563
+ spokenByIndex.clear();
2564
+ assignedCards.length = 0;
2565
+ };
2329
2566
  const modelContext = await loadContactModelContext(controller.signal);
2330
2567
  try {
2331
2568
  // Materialize inside turn try so failures durable-error + settle working.
@@ -2365,7 +2602,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2365
2602
  const result = clearContactMemory(row.assistant_id, {
2366
2603
  upToOrdinal: beforeOrdinal,
2367
2604
  });
2368
- contactResetThisTurn = true;
2605
+ markContactReset(false);
2369
2606
  return result;
2370
2607
  },
2371
2608
  resetContact: () => {
@@ -2374,8 +2611,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2374
2611
  const result = resetContact(row.assistant_id, {
2375
2612
  upToOrdinal: beforeOrdinal,
2376
2613
  });
2377
- contactResetThisTurn = true;
2378
- contactHistoryClearedThisTurn = true;
2614
+ markContactReset(true);
2379
2615
  return result;
2380
2616
  },
2381
2617
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
@@ -2435,7 +2671,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2435
2671
  throw lastError;
2436
2672
  },
2437
2673
  currentAssistant: assistantSnapshot,
2438
- onCard: (card) => assignedCards.push(card),
2674
+ onCard: (card) => {
2675
+ if (contactResetThisTurn) return;
2676
+ rememberPublishedContactCard(row.assistant_id, {
2677
+ userMessageID: messageID, turnID, assignedCards, card,
2678
+ });
2679
+ },
2439
2680
  turnFileParts,
2440
2681
  turnAttachmentScope,
2441
2682
  });
@@ -2453,13 +2694,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2453
2694
  ...modelContext,
2454
2695
  globalEventHub,
2455
2696
  onBubbleDelta: (bubbleIndex, delta, done) => {
2456
- if (controller.signal.aborted) return;
2457
- emitContactTurnEvent('openchamber:contact-bubble-delta', {
2458
- assistantID: row.assistant_id,
2459
- turnID,
2460
- bubbleIndex,
2461
- delta: typeof delta === 'string' ? delta : '',
2462
- done: Boolean(done),
2697
+ if (contactResetThisTurn) return;
2698
+ publishContactBubbleDelta({
2699
+ assistantID: row.assistant_id, turnID, userMessageID: messageID, spokenByIndex, bubbleIndex, delta, done, signal: controller.signal,
2463
2700
  });
2464
2701
  },
2465
2702
  });
@@ -2532,6 +2769,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2532
2769
  bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
2533
2770
  cards,
2534
2771
  turnID,
2772
+ reset: resetThisTurn,
2535
2773
  });
2536
2774
  // persistContactAssistantReply already bumps; clear activity without a second tip.
2537
2775
  settleActiveContactTurn(row.assistant_id, turnID);
@@ -2953,6 +3191,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2953
3191
  }
2954
3192
  }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, markContactRead, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
2955
3193
  cancelAssignedResumes({});
3194
+ sessionAbortInflight.clear();
2956
3195
  for (const turn of contactControllers.values()) turn.controller.abort();
2957
3196
  if (!closed) {
2958
3197
  closed = true;