@datalayer/agent-runtimes 1.5.26 → 1.5.28

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 (49) hide show
  1. package/dist-embed/chunks/{ChatView-CUWE6I5M.js → ChatView-C0PWJ_wj.js} +4 -4
  2. package/dist-embed/datalayer-app-main.js +111 -101
  3. package/lib/apps/apps/AppKept.js +5 -1
  4. package/lib/apps/embed/element.js +10 -4
  5. package/lib/apps/embed/embedConfig.d.ts +10 -1
  6. package/lib/apps/embed/embedConfig.js +25 -1
  7. package/lib/apps/plugins/chat/ChatView.js +2 -2
  8. package/lib/chat/assistant/AssistantStage.js +17 -0
  9. package/lib/chat/assistant/clearance.d.ts +10 -0
  10. package/lib/chat/assistant/clearance.js +14 -0
  11. package/lib/chat/base/ChatBase.js +375 -437
  12. package/lib/chat/base/conversationItems.d.ts +55 -0
  13. package/lib/chat/base/conversationItems.js +126 -0
  14. package/lib/portable/adapterEvents.d.ts +107 -0
  15. package/lib/portable/adapterEvents.js +239 -0
  16. package/lib/portable/approvalCard.d.ts +56 -0
  17. package/lib/portable/approvalCard.js +56 -0
  18. package/lib/portable/conversation.d.ts +71 -18
  19. package/lib/portable/conversation.js +330 -74
  20. package/lib/portable/fixtures.js +12 -0
  21. package/lib/portable/index.d.ts +4 -1
  22. package/lib/portable/index.js +7 -1
  23. package/lib/portable/presence.js +4 -1
  24. package/lib/portable/runMeasures.d.ts +46 -0
  25. package/lib/portable/runMeasures.js +156 -0
  26. package/lib/protocols/ACPAdapter.js +13 -8
  27. package/lib/specs/voices.js +47 -0
  28. package/lib/utils/utils.d.ts +2 -8
  29. package/lib/utils/utils.js +3 -26
  30. package/lib/voice/ComposerVoice.js +25 -3
  31. package/lib/voice/VoiceInput.d.ts +2 -2
  32. package/lib/voice/VoiceInput.js +6 -4
  33. package/lib/voice/VoiceMenu.js +2 -1
  34. package/lib/voice/chatVoice.d.ts +4 -3
  35. package/lib/voice/chatVoice.js +6 -9
  36. package/lib/voice/consent.d.ts +8 -0
  37. package/lib/voice/consent.js +10 -0
  38. package/lib/voice/hearing.d.ts +23 -1
  39. package/lib/voice/hearing.js +18 -3
  40. package/lib/voice/index.d.ts +11 -6
  41. package/lib/voice/index.js +1 -0
  42. package/lib/voice/serverHearing.d.ts +58 -0
  43. package/lib/voice/serverHearing.js +136 -0
  44. package/lib/voice/settings.d.ts +5 -0
  45. package/lib/voice/settings.js +8 -0
  46. package/lib/voice/speaker.d.ts +4 -3
  47. package/lib/voice/speaker.js +1 -2
  48. package/lib/voice/useVoiceSpeaker.js +0 -3
  49. package/package.json +2 -2
@@ -34,9 +34,12 @@ import { useChatStore } from '../../stores/chatStore';
34
34
  import { useConversationStore } from '../../stores/conversationStore';
35
35
  import { askedOf } from '../../portable/records';
36
36
  import { extractChatMessagesFromFullContext } from './fullContextMessages';
37
- import { generateMessageId, createUserMessage, createAssistantMessage, } from '../../types/messages';
37
+ import { generateMessageId, createUserMessage } from '../../types/messages';
38
38
  import { AI_MODEL_CATALOGUE, DEFAULT_MODEL, isChatModel, } from '../../specs/models';
39
- import { internalQueryClient, isToolCallMessage, getMessageText, convertHistoryToDisplayItems, createProtocolAdapter, getApiBaseFromConfig, sanitizeAssistantContent, } from '../../utils';
39
+ import { internalQueryClient, isToolCallMessage, getMessageText, createProtocolAdapter, getApiBaseFromConfig, sanitizeAssistantContent, } from '../../utils';
40
+ import { BROWSER_LIMITS, emptyConversation, reduceConversation, withError, withoutItems, withPrompt, withTurnEnded, } from '../../portable/conversation';
41
+ import { conversationEventsOf, snapshotOfMessages, } from '../../portable/adapterEvents';
42
+ import { conversationDrawer, emptyChatLayer, rowIdOf, toolCallOf, } from './conversationItems';
40
43
  import { useConfig, useSkills, useSkillActions, useContextSnapshot, useSandbox, } from '../../hooks';
41
44
  import { useAgentRuntimeWebSocket } from '../../hooks/useAgentRuntimes';
42
45
  import { agentRuntimeStore, useAgentRuntimeStore, useAgentRuntimeWsState, } from '../../stores/agentRuntimeStore';
@@ -1076,7 +1079,74 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1076
1079
  // Check if protocol is A2A (doesn't support per-request model override)
1077
1080
  const isA2AProtocol = protocol?.type === 'a2a';
1078
1081
  // ---- Component state ----
1079
- const [displayItems, setDisplayItems] = useState([]);
1082
+ /*
1083
+ * The conversation (MOBILE M-030): `reduceConversation`'s, as on the phone,
1084
+ * whatever protocol the agent speaks — its adapter's events normalised by
1085
+ * `conversationEventsOf`. Kept in a ref as each change lands, so the events
1086
+ * of one network chunk each read the last, and drawn on the next render
1087
+ * with what only the web holds beside it (`ChatLayer`).
1088
+ */
1089
+ const [conversation, setConversation] = useState(() => emptyConversation(BROWSER_LIMITS));
1090
+ const conversationRef = useRef(conversation);
1091
+ // Whether anything has changed it yet: a store is not written over by the
1092
+ // empty conversation a chat mounts with.
1093
+ const conversationTouchedRef = useRef(false);
1094
+ const changeConversation = useCallback((change) => {
1095
+ const next = change(conversationRef.current);
1096
+ if (next === conversationRef.current)
1097
+ return;
1098
+ conversationRef.current = next;
1099
+ conversationTouchedRef.current = true;
1100
+ setConversation(next);
1101
+ }, []);
1102
+ const [layer, setLayer] = useState(emptyChatLayer);
1103
+ const layerRef = useRef(layer);
1104
+ const changeLayer = useCallback((change) => {
1105
+ const next = change(layerRef.current);
1106
+ if (next === layerRef.current)
1107
+ return;
1108
+ layerRef.current = next;
1109
+ setLayer(next);
1110
+ }, []);
1111
+ /** The conversation and what the web held beside it, gone: a new one. */
1112
+ const resetConversation = useCallback(() => {
1113
+ changeConversation(() => emptyConversation(BROWSER_LIMITS));
1114
+ changeLayer(() => emptyChatLayer());
1115
+ }, [changeConversation, changeLayer]);
1116
+ /** A conversation read again: its messages, its tool calls and their results. */
1117
+ const restoreConversation = useCallback((messages) => {
1118
+ let restored;
1119
+ try {
1120
+ restored = reduceConversation(emptyConversation(BROWSER_LIMITS), snapshotOfMessages(messages));
1121
+ }
1122
+ catch (failure) {
1123
+ console.error('[ChatBase] Conversation not read again:', failure);
1124
+ setError(failure instanceof Error ? failure : new Error(String(failure)));
1125
+ return;
1126
+ }
1127
+ const extras = new Map();
1128
+ for (const message of messages) {
1129
+ const { id, role: _role, content: _content, toolCalls: _calls, ...rest } = message;
1130
+ extras.set(id, rest);
1131
+ }
1132
+ changeLayer(() => ({ ...emptyChatLayer(), extras }));
1133
+ changeConversation(() => restored);
1134
+ }, [changeConversation, changeLayer]);
1135
+ /** One more input of the reducer. */
1136
+ const reduce = useCallback((event) => changeConversation(state => reduceConversation(state, event)), [changeConversation]);
1137
+ /** A tool call as its card draws it now, or `undefined`. */
1138
+ const toolCardNow = useCallback((toolCallId) => {
1139
+ const row = conversationRef.current.items.find(item => item.kind === 'tool' && item.id === toolCallId);
1140
+ return row && row.kind === 'tool' ? toolCallOf(row) : undefined;
1141
+ }, []);
1142
+ /** Every tool call as its card draws it now. */
1143
+ const toolCardsNow = useCallback(() => conversationRef.current.items.flatMap(item => item.kind === 'tool' ? [toolCallOf(item)] : []), []);
1144
+ const chatTextRef = useRef(chatText);
1145
+ chatTextRef.current = chatText;
1146
+ const drawConversation = useMemo(() => conversationDrawer(text => chatTextRef.current.error(text)), []);
1147
+ const displayItems = useMemo(() => drawConversation(conversation, layer), [drawConversation, conversation, layer]);
1148
+ const displayItemsRef = useRef(displayItems);
1149
+ displayItemsRef.current = displayItems;
1080
1150
  /*
1081
1151
  * What the harness itself reported, for an agent with no server behind it.
1082
1152
  *
@@ -1477,9 +1547,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1477
1547
  // ---- Refs ----
1478
1548
  const adapterRef = useRef(null);
1479
1549
  const unsubscribeRef = useRef(null);
1480
- const toolCallsRef = useRef(new Map());
1481
1550
  const pendingToolExecutionsRef = useRef(0);
1482
- const currentAssistantMessageRef = useRef(null);
1483
1551
  const respondedApprovalIdsRef = useRef(new Set());
1484
1552
  const defaultSkillsBootstrapRef = useRef(new Set());
1485
1553
  const defaultMcpToolsBootstrapRef = useRef(new Set());
@@ -1512,7 +1580,6 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1512
1580
  const uiSocketRef = useRef(null);
1513
1581
  const parkedUiOpsRef = useRef([]);
1514
1582
  const historyStepsRef = useRef(new Set());
1515
- const displayItemsRef = useRef([]);
1516
1583
  const uiSocketUrl = protocol?.uiSocket;
1517
1584
  const uiSocketTokenRef = useRef(protocol?.authToken);
1518
1585
  uiSocketTokenRef.current = protocol?.authToken;
@@ -1532,22 +1599,56 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1532
1599
  token: uiSocketTokenRef.current,
1533
1600
  });
1534
1601
  }, [uiFilesUrl]);
1602
+ /** The messages as a ui op reads them: their ids, what they show, who said them. */
1603
+ const messageStubs = useCallback(() => {
1604
+ const kept = layerRef.current;
1605
+ return conversationRef.current.items.flatMap(item => item.kind === 'message' || item.kind === 'error'
1606
+ ? [
1607
+ {
1608
+ id: item.id,
1609
+ displays: kept.displays.get(item.id),
1610
+ speaker: kept.speakers.get(item.id),
1611
+ },
1612
+ ]
1613
+ : []);
1614
+ }, []);
1615
+ /*
1616
+ * A ui op on the messages: what they show and who said them are the web's
1617
+ * (`ChatLayer`); a message taken back leaves the conversation.
1618
+ */
1535
1619
  const applyToMessages = useCallback((op) => {
1536
- const apply = (items) => {
1537
- try {
1538
- return withUiOp(items, op);
1539
- }
1540
- catch (error) {
1541
- console.error('[ChatBase] reactor.ui op not applied:', error);
1542
- return items;
1543
- }
1544
- };
1545
- setDisplayItems(prev => apply(prev));
1546
- if (useStoreMode) {
1547
- const store = useChatStore.getState();
1548
- store.setMessages(apply(store.messages));
1620
+ const stubs = messageStubs();
1621
+ let next;
1622
+ try {
1623
+ next = withUiOp(stubs, op);
1549
1624
  }
1550
- }, [useStoreMode]);
1625
+ catch (error) {
1626
+ console.error('[ChatBase] reactor.ui op not applied:', error);
1627
+ return;
1628
+ }
1629
+ const left = new Set(next.map(stub => stub.id));
1630
+ const removed = stubs
1631
+ .filter(stub => !left.has(stub.id))
1632
+ .map(stub => stub.id);
1633
+ changeLayer(kept => {
1634
+ const displays = new Map(kept.displays);
1635
+ const speakers = new Map(kept.speakers);
1636
+ for (const stub of next) {
1637
+ if (stub.displays)
1638
+ displays.set(stub.id, stub.displays);
1639
+ if (stub.speaker)
1640
+ speakers.set(stub.id, stub.speaker);
1641
+ }
1642
+ for (const id of removed) {
1643
+ displays.delete(id);
1644
+ speakers.delete(id);
1645
+ }
1646
+ return { ...kept, displays, speakers };
1647
+ });
1648
+ if (removed.length > 0) {
1649
+ changeConversation(state => withoutItems(state, removed));
1650
+ }
1651
+ }, [messageStubs, changeLayer, changeConversation]);
1551
1652
  const applySocketOp = useCallback((op, history) => {
1552
1653
  // The past is drawn again, never acted on: a window message is not
1553
1654
  // raised again, and the steps of turns long over are not drawn again.
@@ -1580,32 +1681,40 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1580
1681
  else if (routed.to === 'step') {
1581
1682
  // A step of its code, drawn as it runs (LOOP P-16).
1582
1683
  const step = routed.step;
1583
- setDisplayItems(prev => withLoopStep(prev, step));
1684
+ changeLayer(kept => {
1685
+ const anchors = new Map(kept.steps.map(row => [row.item.id, row.after]));
1686
+ const last = conversationRef.current.items.at(-1)?.id ?? null;
1687
+ const items = withLoopStep(kept.steps.map(row => row.item), step);
1688
+ return {
1689
+ ...kept,
1690
+ steps: items.map(item => ({
1691
+ item,
1692
+ after: anchors.has(item.id)
1693
+ ? (anchors.get(item.id) ?? null)
1694
+ : last,
1695
+ })),
1696
+ };
1697
+ });
1584
1698
  }
1585
1699
  else if (routed.to === 'element') {
1586
1700
  // In a side panel or on a page of its own (LOOP P-18).
1587
1701
  uiElementsRef.current.add(routed.change.id);
1588
1702
  applyLoopElement(routed.change);
1589
1703
  }
1590
- else if (!placeable(displayItemsRef.current, routed.op)) {
1704
+ else if (!placeable(messageStubs(), routed.op)) {
1591
1705
  const under = routed.op.op === 'show' || routed.op.op === 'ask'
1592
1706
  ? routed.op.body.data?.message_id
1593
1707
  : undefined;
1594
1708
  if (history && typeof under === 'string') {
1595
1709
  // Shown in the past under a message that said nothing: the
1596
1710
  // thread did not draw it, the chat draws it for what it showed.
1597
- const placeholder = {
1598
- id: under,
1711
+ reduce({
1712
+ type: 'MESSAGE_SAID',
1713
+ messageId: under,
1599
1714
  role: 'assistant',
1600
- content: '',
1601
- createdAt: new Date(),
1602
- };
1603
- displayItemsRef.current = [...displayItemsRef.current, placeholder];
1604
- setDisplayItems(prev => prev.some(item => item.id === under)
1605
- ? prev
1606
- : [...prev, placeholder]);
1607
- if (useStoreMode)
1608
- useChatStore.getState().addMessage(placeholder);
1715
+ text: '',
1716
+ streaming: false,
1717
+ });
1609
1718
  applyToMessages(routed.op);
1610
1719
  }
1611
1720
  else {
@@ -1615,30 +1724,22 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1615
1724
  else {
1616
1725
  applyToMessages(routed.op);
1617
1726
  }
1618
- }, [applyToMessages, useStoreMode]);
1727
+ }, [applyToMessages, messageStubs, changeLayer, reduce]);
1619
1728
  const applySocketOpRef = useRef(applySocketOp);
1620
1729
  applySocketOpRef.current = applySocketOp;
1621
1730
  // What waited for its message, applied once the message is here.
1622
1731
  useEffect(() => {
1623
- displayItemsRef.current = displayItems;
1624
1732
  if (parkedUiOpsRef.current.length === 0)
1625
1733
  return;
1626
1734
  const parked = parkedUiOpsRef.current.splice(0);
1627
- let items = displayItems;
1628
1735
  for (const op of parked) {
1629
- if (!placeable(items, op)) {
1736
+ if (!placeable(messageStubs(), op)) {
1630
1737
  parkedUiOpsRef.current.push(op);
1631
1738
  continue;
1632
1739
  }
1633
- try {
1634
- items = withUiOp(items, op);
1635
- }
1636
- catch {
1637
- // Said by `applyToMessages`, which refuses it the same way.
1638
- }
1639
1740
  applyToMessages(op);
1640
1741
  }
1641
- }, [displayItems, applyToMessages]);
1742
+ }, [conversation, applyToMessages, messageStubs]);
1642
1743
  /** The conversation's ui socket, opened for its thread when it is not yet. */
1643
1744
  const ensureUiSocket = useCallback(() => {
1644
1745
  if (!uiSocketUrl)
@@ -1732,7 +1833,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1732
1833
  if (!respond)
1733
1834
  return;
1734
1835
  const normalize = (name) => (name ?? '').toLowerCase().replace(/[-_]/g, '');
1735
- const calls = Array.from(toolCallsRef.current.values());
1836
+ const calls = toolCardsNow();
1736
1837
  // By the approval id the tool result carries, else by the tool
1737
1838
  // name of a call still waiting — the same two matches the card makes.
1738
1839
  const call = calls.find(entry => entry.result
@@ -1758,14 +1859,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1758
1859
  }
1759
1860
  let targetToolCallId = approval.tool_call_id;
1760
1861
  if (!targetToolCallId) {
1761
- for (const [tcId, tc] of toolCallsRef.current.entries()) {
1862
+ for (const tc of toolCardsNow()) {
1762
1863
  if (tc.status !== 'inProgress' && tc.status !== 'executing') {
1763
1864
  continue;
1764
1865
  }
1765
1866
  const tcSig = approvalSignature(tc.toolName, tc.args ?? {});
1766
1867
  const approvalSig = approvalSignature(approval.tool_name, approval.tool_args ?? {});
1767
1868
  if (tcSig === approvalSig) {
1768
- targetToolCallId = tcId;
1869
+ targetToolCallId = tc.toolCallId;
1769
1870
  break;
1770
1871
  }
1771
1872
  }
@@ -1773,7 +1874,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
1773
1874
  if (!targetToolCallId) {
1774
1875
  return false;
1775
1876
  }
1776
- const target = toolCallsRef.current.get(targetToolCallId);
1877
+ const target = toolCardNow(targetToolCallId);
1777
1878
  if (!target) {
1778
1879
  return false;
1779
1880
  }
@@ -2236,10 +2337,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2236
2337
  if (useStoreMode && !runtimeId) {
2237
2338
  const storeMessages = useChatStore.getState().messages;
2238
2339
  if (storeMessages.length > 0) {
2239
- setDisplayItems(storeMessages);
2340
+ restoreConversation(storeMessages);
2240
2341
  }
2241
2342
  }
2242
- }, [useStoreMode, runtimeId]);
2343
+ }, [useStoreMode, runtimeId, restoreConversation]);
2243
2344
  // ---- Conversation history loading ----
2244
2345
  const prevHistoryScopeRef = useRef(undefined);
2245
2346
  const historyVersionRef = useRef(0);
@@ -2254,8 +2355,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2254
2355
  historyRetryAttemptsRef.current.set(historyScopeId, 0);
2255
2356
  }
2256
2357
  prevHistoryScopeRef.current = historyScopeId;
2257
- setDisplayItems([]);
2258
- toolCallsRef.current.clear();
2358
+ resetConversation();
2259
2359
  if (!historyScopeId)
2260
2360
  return;
2261
2361
  }
@@ -2273,7 +2373,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2273
2373
  useConversationStore
2274
2374
  .getState()
2275
2375
  .setMessages(historyScopeId, given.messages);
2276
- setDisplayItems(convertHistoryToDisplayItems(given.messages));
2376
+ restoreConversation(given.messages);
2277
2377
  useConversationStore.getState().markFetched(historyScopeId);
2278
2378
  setHistoryLoaded(true);
2279
2379
  }
@@ -2285,8 +2385,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2285
2385
  // The transcript changed on the server outside a run — a rewind to a
2286
2386
  // checkpoint. Forget what is shown and load what the agent now has.
2287
2387
  useConversationStore.getState().clearMessages(historyScopeId);
2288
- setDisplayItems([]);
2289
- toolCallsRef.current.clear();
2388
+ resetConversation();
2290
2389
  historyRetryAttemptsRef.current.set(historyScopeId, 0);
2291
2390
  freshSnapshotRef.current = true;
2292
2391
  }
@@ -2300,7 +2399,9 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2300
2399
  // state — put over a live conversation, it erased its tool cards, a
2301
2400
  // pending approval among them, at the socket's first retry.
2302
2401
  if (storedMessages.length > 0) {
2303
- setDisplayItems(prev => (prev.length > 0 ? prev : storedMessages));
2402
+ if (conversationRef.current.items.length === 0) {
2403
+ restoreConversation(storedMessages);
2404
+ }
2304
2405
  setHistoryLoaded(true);
2305
2406
  }
2306
2407
  if (currentlyFetching) {
@@ -2318,7 +2419,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2318
2419
  const fullContextToMessages = () => extractChatMessagesFromFullContext(agentRuntimeStore.getState().fullContext);
2319
2420
  const applyMessages = (messages) => {
2320
2421
  store.setMessages(historyScopeId, messages);
2321
- setDisplayItems(convertHistoryToDisplayItems(messages));
2422
+ restoreConversation(messages);
2322
2423
  historyRetryAttemptsRef.current.set(historyScopeId, 0);
2323
2424
  store.markFetched(historyScopeId);
2324
2425
  setHistoryLoaded(true);
@@ -2416,6 +2517,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2416
2517
  activeAgentId,
2417
2518
  historyRefreshTick,
2418
2519
  historyVersion,
2520
+ resetConversation,
2521
+ restoreConversation,
2419
2522
  ]);
2420
2523
  // Keep in-memory store in sync with displayItems
2421
2524
  useEffect(() => {
@@ -2428,6 +2531,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2428
2531
  }
2429
2532
  }
2430
2533
  }, [historyScopeId, displayItems]);
2534
+ // A store-mode chat keeps its messages in the shared chat store, as drawn.
2535
+ useEffect(() => {
2536
+ if (!useStoreMode || !conversationTouchedRef.current)
2537
+ return;
2538
+ useChatStore
2539
+ .getState()
2540
+ .setMessages(displayItems.filter((item) => !isToolCallMessage(item)));
2541
+ }, [useStoreMode, displayItems]);
2431
2542
  // ---- Derived state ----
2432
2543
  const messages = displayItems.filter((item) => !isToolCallMessage(item));
2433
2544
  const ready = true;
@@ -2516,6 +2627,45 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2516
2627
  event.type !== 'done') {
2517
2628
  return;
2518
2629
  }
2630
+ /*
2631
+ * The event, as the conversation reads it (`conversationEventsOf`):
2632
+ * one reducer whatever the protocol. What it cannot read is said.
2633
+ */
2634
+ const read = () => {
2635
+ try {
2636
+ const inputs = conversationEventsOf(event, conversationRef.current);
2637
+ changeConversation(state => inputs.reduce((kept, input) => reduceConversation(kept, input), state));
2638
+ }
2639
+ catch (failure) {
2640
+ console.error('[ChatBase] Protocol event not read:', failure);
2641
+ setError(failure instanceof Error ? failure : new Error(String(failure)));
2642
+ }
2643
+ };
2644
+ /** The conversation's latest tool row with an answer, if any said nothing. */
2645
+ const toolResultFallback = () => {
2646
+ const items = conversationRef.current.items;
2647
+ const hasAssistantContent = items.some(item => item.kind === 'message' &&
2648
+ item.role === 'assistant' &&
2649
+ item.text.trim().length > 0);
2650
+ if (hasAssistantContent) {
2651
+ return;
2652
+ }
2653
+ const latestCompletedTool = [...items]
2654
+ .reverse()
2655
+ .find(item => item.kind === 'tool' &&
2656
+ item.status === 'complete' &&
2657
+ item.value !== undefined);
2658
+ if (!latestCompletedTool || latestCompletedTool.kind !== 'tool') {
2659
+ return;
2660
+ }
2661
+ reduce({
2662
+ type: 'MESSAGE_SAID',
2663
+ messageId: generateMessageId(),
2664
+ role: 'assistant',
2665
+ text: formatToolResultFallback(latestCompletedTool.value),
2666
+ streaming: false,
2667
+ });
2668
+ };
2519
2669
  switch (event.type) {
2520
2670
  case 'message':
2521
2671
  if (event.usage) {
@@ -2545,95 +2695,21 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2545
2695
  });
2546
2696
  }
2547
2697
  }
2698
+ // A prompt that only calls a tool shows the tool, not its words.
2548
2699
  if (suppressAssistantTextForToolOnlyRef.current) {
2549
- const suppressedMessageId = currentAssistantMessageRef.current?.id;
2550
- if (suppressedMessageId) {
2551
- setDisplayItems(prev => prev.filter(item => isToolCallMessage(item) || item.id !== suppressedMessageId));
2552
- if (useStoreMode) {
2553
- useChatStore.getState().deleteMessage(suppressedMessageId);
2554
- }
2555
- currentAssistantMessageRef.current = null;
2556
- }
2557
2700
  break;
2558
2701
  }
2559
2702
  if (event.message) {
2560
- const incomingId = event.message.id;
2561
- const currentId = currentAssistantMessageRef.current?.id;
2562
- const isNewMessage = !currentId || (incomingId && incomingId !== currentId);
2563
- if (currentId && !isNewMessage) {
2564
- /*
2565
- * The message to write to is the one current now, as the event
2566
- * arrives — not whichever the ref names when React gets round
2567
- * to running the updater. A whole answer in one network chunk
2568
- * queues every delta's updater in the same task, and the turn's
2569
- * end clears the ref before any of them runs: read lazily, all
2570
- * but the first found nothing to update, and the conversation
2571
- * and the balloon showed only the answer's first word.
2572
- */
2573
- const rawContent = event.message.content;
2574
- const sanitizedContent = typeof rawContent === 'string'
2575
- ? sanitizeAssistantContent(rawContent)
2576
- : (rawContent ?? '');
2577
- if (typeof sanitizedContent === 'string' && sanitizedContent) {
2578
- inspectAnswerRef.current = sanitizedContent;
2579
- }
2580
- setDisplayItems(prev => {
2581
- const newItems = [...prev];
2582
- const idx = newItems.findIndex(item => !isToolCallMessage(item) && item.id === currentId);
2583
- if (idx >= 0 && !isToolCallMessage(newItems[idx])) {
2584
- newItems[idx] = {
2585
- ...newItems[idx],
2586
- content: sanitizedContent,
2587
- };
2588
- }
2589
- return newItems;
2590
- });
2591
- if (useStoreMode) {
2592
- useChatStore.getState().updateMessage(currentId, {
2593
- content: sanitizedContent,
2594
- });
2595
- }
2596
- }
2597
- else {
2598
- const content = event.message.content;
2599
- const contentStr = typeof content === 'string' ? content : (content ?? '');
2600
- const sanitizedContent = typeof contentStr === 'string'
2601
- ? sanitizeAssistantContent(contentStr)
2602
- : '';
2603
- const newMessage = createAssistantMessage(sanitizedContent);
2604
- newMessage.id = event.message.id || newMessage.id;
2605
- currentAssistantMessageRef.current = newMessage;
2606
- setDisplayItems(prev => {
2607
- const existingIdx = prev.findIndex(item => !isToolCallMessage(item) && item.id === newMessage.id);
2608
- if (existingIdx >= 0) {
2609
- const newItems = [...prev];
2610
- newItems[existingIdx] = {
2611
- ...newItems[existingIdx],
2612
- content: sanitizedContent,
2613
- };
2614
- return newItems;
2615
- }
2616
- return [...prev, newMessage];
2617
- });
2618
- if (useStoreMode) {
2619
- const existingInStore = useChatStore
2620
- .getState()
2621
- .messages.find(m => m.id === newMessage.id);
2622
- if (existingInStore) {
2623
- useChatStore.getState().updateMessage(newMessage.id, {
2624
- content: sanitizedContent,
2625
- });
2626
- }
2627
- else {
2628
- useChatStore.getState().addMessage(newMessage);
2629
- }
2630
- }
2703
+ const content = event.message.content;
2704
+ if (typeof content === 'string' && content) {
2705
+ inspectAnswerRef.current = sanitizeAssistantContent(content);
2631
2706
  }
2707
+ read();
2632
2708
  }
2633
2709
  break;
2634
2710
  case 'tool-call':
2635
2711
  if (event.toolCall && !stoppedRef.current) {
2636
- const toolCallId = event.toolCall.toolCallId || generateMessageId();
2712
+ const toolCallId = event.toolCall.toolCallId;
2637
2713
  const toolName = event.toolCall.toolName;
2638
2714
  const args = event.toolCall.args || {};
2639
2715
  /*
@@ -2644,38 +2720,23 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2644
2720
  * `false`. Without the flag, the old heuristics decide.
2645
2721
  */
2646
2722
  const argsComplete = event.toolCall.argsComplete;
2647
- if (toolCallsRef.current.has(toolCallId)) {
2648
- const existingToolCall = toolCallsRef.current.get(toolCallId);
2649
- if (existingToolCall) {
2650
- const updatedToolCall = {
2651
- ...existingToolCall,
2652
- args: { ...existingToolCall.args, ...args },
2653
- };
2654
- toolCallsRef.current.set(toolCallId, updatedToolCall);
2655
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) && item.toolCallId === toolCallId
2656
- ? updatedToolCall
2657
- : item));
2658
- const frontendTool = frontendToolsRef.current?.find(t => t.name === toolName);
2659
- const toolHandler = frontendTool?.handler;
2660
- if (toolHandler &&
2661
- existingToolCall.status === 'executing' &&
2662
- (argsComplete ?? Object.keys(args).length > 0)) {
2663
- pendingToolExecutionsRef.current++;
2664
- executeFrontendTool(toolHandler, updatedToolCall, toolCallId);
2665
- }
2723
+ const known = toolCardNow(toolCallId);
2724
+ read();
2725
+ const card = toolCardNow(toolCallId);
2726
+ if (!card) {
2727
+ break;
2728
+ }
2729
+ const frontendTool = frontendToolsRef.current?.find(t => t.name === toolName);
2730
+ const toolHandler = frontendTool?.handler;
2731
+ if (known) {
2732
+ if (toolHandler &&
2733
+ known.status === 'executing' &&
2734
+ (argsComplete ?? Object.keys(args).length > 0)) {
2735
+ pendingToolExecutionsRef.current++;
2736
+ executeFrontendTool(toolHandler, card, toolCallId);
2666
2737
  }
2667
2738
  }
2668
2739
  else {
2669
- const toolCallMsg = {
2670
- id: `tool-${toolCallId}`,
2671
- type: 'tool-call',
2672
- toolCallId,
2673
- toolName,
2674
- args,
2675
- status: 'executing',
2676
- };
2677
- toolCallsRef.current.set(toolCallId, toolCallMsg);
2678
- setDisplayItems(prev => [...prev, toolCallMsg]);
2679
2740
  // Fire pre-hook for new tool calls
2680
2741
  onToolCallStartRef.current?.({
2681
2742
  toolName,
@@ -2687,8 +2748,6 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2687
2748
  name: toolName,
2688
2749
  args,
2689
2750
  });
2690
- const frontendTool = frontendToolsRef.current?.find(t => t.name === toolName);
2691
- const toolHandler = frontendTool?.handler;
2692
2751
  // Execute when the args are complete. The protocol says so
2693
2752
  // when it can (`argsComplete`); without the flag, fall back
2694
2753
  // to the old heuristics — actual args present, or a tool
@@ -2701,129 +2760,48 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2701
2760
  (Object.keys(args).length > 0 ||
2702
2761
  !frontendToolExpectsArgs(frontendTool)))) {
2703
2762
  pendingToolExecutionsRef.current++;
2704
- executeFrontendTool(toolHandler, toolCallMsg, toolCallId);
2763
+ executeFrontendTool(toolHandler, card, toolCallId);
2705
2764
  }
2706
2765
  }
2707
2766
  }
2708
2767
  break;
2709
2768
  case 'tool-result':
2710
2769
  if (event.toolResult) {
2711
- const toolCallId = event.toolResult.toolCallId;
2712
- if (toolCallId && toolCallsRef.current.has(toolCallId)) {
2713
- const existingToolCall = toolCallsRef.current.get(toolCallId);
2714
- if (existingToolCall) {
2715
- const isHumanInTheLoop = existingToolCall.args &&
2716
- 'steps' in existingToolCall.args &&
2717
- Array.isArray(existingToolCall.args.steps);
2718
- const resultData = event.toolResult.result;
2719
- let executionError;
2720
- let codeError;
2721
- let exitCode;
2722
- let isPendingApproval = false;
2723
- let hasError = !!event.toolResult.error;
2724
- if (resultData && typeof resultData === 'object') {
2725
- if ('pending_approval' in resultData &&
2726
- resultData.pending_approval === true) {
2727
- isPendingApproval = true;
2728
- }
2729
- if (resultData.execution_error &&
2730
- typeof resultData.execution_error === 'string') {
2731
- executionError = resultData.execution_error;
2732
- hasError = true;
2733
- }
2734
- if (resultData.code_error &&
2735
- typeof resultData.code_error === 'object') {
2736
- const ce = resultData.code_error;
2737
- codeError = {
2738
- name: ce.name || 'Error',
2739
- value: ce.value || 'Unknown error',
2740
- traceback: ce.traceback,
2741
- };
2742
- hasError = true;
2743
- }
2744
- if ('exit_code' in resultData) {
2745
- const ec = resultData.exit_code;
2746
- exitCode = typeof ec === 'number' ? ec : null;
2747
- if (exitCode != null && exitCode !== 0)
2748
- hasError = true;
2749
- }
2750
- if ('execution_ok' in resultData &&
2751
- resultData.execution_ok === false) {
2752
- hasError = true;
2753
- }
2754
- }
2755
- const updatedToolCall = {
2756
- ...existingToolCall,
2757
- result: event.toolResult.result,
2758
- status: hasError
2759
- ? 'error'
2760
- : isPendingApproval
2761
- ? 'inProgress'
2762
- : isHumanInTheLoop
2763
- ? 'executing'
2764
- : 'complete',
2765
- error: event.toolResult.error,
2766
- executionError,
2767
- codeError,
2768
- exitCode,
2769
- };
2770
- toolCallsRef.current.set(toolCallId, updatedToolCall);
2771
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) && item.toolCallId === toolCallId
2772
- ? updatedToolCall
2773
- : item));
2774
- recorderRef.current?.recorder.toolEnded(toolCallId, {
2775
- result: event.toolResult.result,
2776
- error: event.toolResult.error ??
2777
- (updatedToolCall.status === 'error'
2778
- ? (executionError ?? 'failed')
2779
- : undefined),
2780
- });
2781
- // Fire post-hook for tool results
2782
- onToolCallCompleteRef.current?.({
2783
- toolName: existingToolCall.toolName,
2784
- toolCallId,
2785
- args: existingToolCall.args,
2786
- result: event.toolResult.result,
2787
- status: updatedToolCall.status,
2788
- error: event.toolResult.error,
2789
- });
2790
- }
2770
+ const toolCallId = event.toolResult.toolCallId ?? '';
2771
+ const known = toolCardNow(toolCallId);
2772
+ read();
2773
+ const card = toolCardNow(toolCallId);
2774
+ if (known && card) {
2775
+ recorderRef.current?.recorder.toolEnded(toolCallId, {
2776
+ result: event.toolResult.result,
2777
+ error: event.toolResult.error ??
2778
+ (card.status === 'error'
2779
+ ? (card.executionError ?? 'failed')
2780
+ : undefined),
2781
+ });
2782
+ // Fire post-hook for tool results
2783
+ onToolCallCompleteRef.current?.({
2784
+ toolName: known.toolName,
2785
+ toolCallId,
2786
+ args: known.args,
2787
+ result: event.toolResult.result,
2788
+ status: card.status,
2789
+ error: event.toolResult.error,
2790
+ });
2791
2791
  }
2792
2792
  }
2793
2793
  break;
2794
2794
  case 'state-update':
2795
2795
  onStateUpdate?.(event.data);
2796
- if (event.data) {
2797
- const executingToolCalls = Array.from(toolCallsRef.current.entries()).filter(([_, tc]) => tc.status === 'executing');
2798
- if (executingToolCalls.length > 0) {
2799
- const [lastToolCallId, existingToolCall] = executingToolCalls[executingToolCalls.length - 1];
2800
- const isHumanInTheLoop = existingToolCall.args &&
2801
- 'steps' in existingToolCall.args &&
2802
- Array.isArray(existingToolCall.args.steps);
2803
- if (!isHumanInTheLoop) {
2804
- const stateData = event.data;
2805
- const result = stateData.weather ??
2806
- stateData.result ??
2807
- stateData.toolResult ??
2808
- stateData;
2809
- const updatedToolCall = {
2810
- ...existingToolCall,
2811
- result,
2812
- status: 'complete',
2813
- };
2814
- toolCallsRef.current.set(lastToolCallId, updatedToolCall);
2815
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) &&
2816
- item.toolCallId === lastToolCallId
2817
- ? updatedToolCall
2818
- : item));
2819
- }
2820
- }
2821
- }
2796
+ // A tool that answers by setting the shared state: the newest
2797
+ // running call is answered with it (`conversationEventsOf`).
2798
+ read();
2822
2799
  break;
2823
2800
  case 'activity':
2824
2801
  // What the server says besides the conversation's text comes on
2825
- // the ui socket (REACTOR_UI RU-W); the run's other activities —
2826
- // `loop.session`, `loop.page` — are their readers'.
2802
+ // the ui socket (REACTOR_UI RU-W); the run's own — `loop.session`
2803
+ // among them — are the conversation's.
2804
+ read();
2827
2805
  break;
2828
2806
  case 'done':
2829
2807
  /*
@@ -2863,28 +2841,11 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2863
2841
  // (including all continuations) has finished.
2864
2842
  if (suppressAssistantTextForToolOnlyRef.current &&
2865
2843
  hideMessagesAfterToolUIRef.current) {
2866
- setDisplayItems(prev => {
2867
- const hasAssistantContent = prev.some(item => !isToolCallMessage(item) &&
2868
- item.role === 'assistant' &&
2869
- String(item.content || '').trim().length > 0);
2870
- if (hasAssistantContent) {
2871
- return prev;
2872
- }
2873
- const latestCompletedTool = [...prev]
2874
- .reverse()
2875
- .find(item => isToolCallMessage(item) &&
2876
- item.status === 'complete' &&
2877
- item.result !== undefined);
2878
- if (!latestCompletedTool ||
2879
- !isToolCallMessage(latestCompletedTool)) {
2880
- return prev;
2881
- }
2882
- const fallbackMessage = createAssistantMessage(formatToolResultFallback(latestCompletedTool.result));
2883
- if (useStoreMode) {
2884
- useChatStore.getState().addMessage(fallbackMessage);
2885
- }
2886
- return [...prev, fallbackMessage];
2887
- });
2844
+ toolResultFallback();
2845
+ }
2846
+ // A stopped turn was ended when it was stopped.
2847
+ if (!stoppedRef.current) {
2848
+ read();
2888
2849
  }
2889
2850
  suppressAssistantTextForToolOnlyRef.current = false;
2890
2851
  pendingToolExecutionsRef.current = 0;
@@ -2900,29 +2861,9 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2900
2861
  if (event.error?.message &&
2901
2862
  /exceeded maximum retries/i.test(event.error.message) &&
2902
2863
  hideMessagesAfterToolUIRef.current) {
2903
- setDisplayItems(prev => {
2904
- const hasAssistantContent = prev.some(item => !isToolCallMessage(item) &&
2905
- item.role === 'assistant' &&
2906
- String(item.content || '').trim().length > 0);
2907
- if (hasAssistantContent) {
2908
- return prev;
2909
- }
2910
- const latestCompletedTool = [...prev]
2911
- .reverse()
2912
- .find(item => isToolCallMessage(item) &&
2913
- item.status === 'complete' &&
2914
- item.result !== undefined);
2915
- if (!latestCompletedTool ||
2916
- !isToolCallMessage(latestCompletedTool)) {
2917
- return prev;
2918
- }
2919
- const fallbackMessage = createAssistantMessage(formatToolResultFallback(latestCompletedTool.result));
2920
- if (useStoreMode) {
2921
- useChatStore.getState().addMessage(fallbackMessage);
2922
- }
2923
- return [...prev, fallbackMessage];
2924
- });
2864
+ toolResultFallback();
2925
2865
  }
2866
+ read();
2926
2867
  suppressAssistantTextForToolOnlyRef.current = false;
2927
2868
  setError(event.error || new Error('Unknown error'));
2928
2869
  pendingToolExecutionsRef.current = 0;
@@ -2930,6 +2871,9 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2930
2871
  setIsStreaming(false);
2931
2872
  agentRuntimeStore.getState().requestRefresh(activeAgentId);
2932
2873
  break;
2874
+ default:
2875
+ read();
2876
+ break;
2933
2877
  }
2934
2878
  });
2935
2879
  adapter.connect().catch(console.error);
@@ -2959,31 +2903,25 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2959
2903
  result,
2960
2904
  });
2961
2905
  }
2962
- const completedToolCall = {
2963
- ...toolCallMsg,
2964
- result,
2965
- status: 'complete',
2966
- };
2967
2906
  recorderRef.current?.recorder.toolEnded(toolCallId, { result });
2968
- toolCallsRef.current.set(toolCallId, completedToolCall);
2969
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) && item.toolCallId === toolCallId
2970
- ? completedToolCall
2971
- : item));
2907
+ reduce({
2908
+ type: 'TOOL_CALL_DONE',
2909
+ toolCallId,
2910
+ success: true,
2911
+ result,
2912
+ });
2972
2913
  }
2973
2914
  catch (err) {
2974
2915
  console.error('[ChatBase] Frontend tool execution error:', err);
2975
- const errorToolCall = {
2976
- ...toolCallMsg,
2977
- status: 'error',
2978
- error: err.message,
2979
- };
2980
2916
  recorderRef.current?.recorder.toolEnded(toolCallId, {
2981
2917
  error: err.message,
2982
2918
  });
2983
- toolCallsRef.current.set(toolCallId, errorToolCall);
2984
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) && item.toolCallId === toolCallId
2985
- ? errorToolCall
2986
- : item));
2919
+ reduce({
2920
+ type: 'TOOL_CALL_DONE',
2921
+ toolCallId,
2922
+ success: false,
2923
+ error: err.message,
2924
+ });
2987
2925
  }
2988
2926
  finally {
2989
2927
  pendingToolExecutionsRef.current--;
@@ -3093,62 +3031,53 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3093
3031
  const userMessage = createUserMessage(messageContent, spoken ? { ...spoken } : undefined);
3094
3032
  const currentMessages = displayItems.filter((item) => !isToolCallMessage(item));
3095
3033
  const allMessages = [...currentMessages, userMessage];
3096
- setDisplayItems(prev => [...prev, userMessage]);
3034
+ {
3035
+ const { id, role: _role, content: _content, ...extras } = userMessage;
3036
+ changeLayer(kept => ({
3037
+ ...kept,
3038
+ extras: new Map(kept.extras).set(id, extras),
3039
+ }));
3040
+ }
3041
+ changeConversation(state => reduceConversation(withPrompt(state, userMessage.id, messageContent), {
3042
+ type: 'RUN_STARTED',
3043
+ }));
3097
3044
  setInput('');
3098
3045
  setIsLoading(true);
3099
3046
  setIsStreaming(true);
3100
3047
  setError(null);
3101
- currentAssistantMessageRef.current = null;
3102
- if (useStoreMode) {
3103
- useChatStore.getState().addMessage(userMessage);
3104
- }
3105
3048
  try {
3106
3049
  if (onSendMessage) {
3107
3050
  if (enableStreaming) {
3108
3051
  const assistantMessageId = generateMessageId();
3109
- const assistantMessage = createAssistantMessage('');
3110
- assistantMessage.id = assistantMessageId;
3111
- setDisplayItems(prev => [...prev, assistantMessage]);
3112
- currentAssistantMessageRef.current = assistantMessage;
3052
+ const said = (text, streaming) => reduce({
3053
+ type: 'MESSAGE_SAID',
3054
+ messageId: assistantMessageId,
3055
+ role: 'assistant',
3056
+ text,
3057
+ streaming,
3058
+ });
3059
+ said('', true);
3113
3060
  if (useStoreMode) {
3114
- useChatStore.getState().addMessage(assistantMessage);
3115
3061
  useChatStore.getState().startStreaming(assistantMessageId);
3116
3062
  }
3117
3063
  abortControllerRef.current = new AbortController();
3118
3064
  await onSendMessage(messageContent, allMessages, {
3119
3065
  onChunk: (chunk) => {
3120
- setDisplayItems(prev => prev.map(item => item.id === assistantMessageId
3121
- ? {
3122
- ...item,
3123
- content: item.content + chunk,
3124
- }
3125
- : item));
3126
- if (useStoreMode) {
3127
- useChatStore
3128
- .getState()
3129
- .appendToStream(assistantMessageId, chunk);
3130
- }
3066
+ reduce({
3067
+ type: 'TEXT_MESSAGE_CONTENT',
3068
+ messageId: assistantMessageId,
3069
+ delta: chunk,
3070
+ });
3131
3071
  },
3132
3072
  onComplete: (fullResponse) => {
3133
- setDisplayItems(prev => prev.map(item => item.id === assistantMessageId
3134
- ? { ...item, content: fullResponse }
3135
- : item));
3073
+ said(fullResponse, false);
3136
3074
  if (useStoreMode) {
3137
- useChatStore.getState().updateMessage(assistantMessageId, {
3138
- content: fullResponse,
3139
- });
3140
3075
  useChatStore.getState().stopStreaming();
3141
3076
  }
3142
3077
  },
3143
3078
  onError: (error) => {
3144
- const errorContent = chatText.error(error.message);
3145
- setDisplayItems(prev => prev.map(item => item.id === assistantMessageId
3146
- ? { ...item, content: errorContent }
3147
- : item));
3079
+ said(chatText.error(error.message), false);
3148
3080
  if (useStoreMode) {
3149
- useChatStore.getState().updateMessage(assistantMessageId, {
3150
- content: errorContent,
3151
- });
3152
3081
  useChatStore.getState().stopStreaming();
3153
3082
  }
3154
3083
  setError(error);
@@ -3159,11 +3088,13 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3159
3088
  else {
3160
3089
  const response = await onSendMessage(messageContent, allMessages);
3161
3090
  if (response) {
3162
- const assistantMessage = createAssistantMessage(response);
3163
- setDisplayItems(prev => [...prev, assistantMessage]);
3164
- if (useStoreMode) {
3165
- useChatStore.getState().addMessage(assistantMessage);
3166
- }
3091
+ reduce({
3092
+ type: 'MESSAGE_SAID',
3093
+ messageId: generateMessageId(),
3094
+ role: 'assistant',
3095
+ text: response,
3096
+ streaming: false,
3097
+ });
3167
3098
  }
3168
3099
  }
3169
3100
  }
@@ -3193,8 +3124,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3193
3124
  catch (err) {
3194
3125
  if (err.name !== 'AbortError') {
3195
3126
  console.error('[ChatBase] Send error:', err);
3196
- const errorMessage = createAssistantMessage(chatText.error(err.message));
3197
- setDisplayItems(prev => [...prev, errorMessage]);
3127
+ // Said in the conversation, as the assistant's words.
3128
+ changeConversation(state => withError(state, err.message));
3198
3129
  setError(err);
3199
3130
  }
3200
3131
  }
@@ -3205,12 +3136,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3205
3136
  // For the non-adapter path (onSendMessage), 'done' is never
3206
3137
  // emitted so we reset when no adapter is present.
3207
3138
  if (!adapterRef.current) {
3139
+ if (conversationRef.current.running) {
3140
+ reduce({ type: 'RUN_FINISHED' });
3141
+ }
3208
3142
  setIsLoading(false);
3209
3143
  setIsStreaming(false);
3210
3144
  agentRuntimeStore.getState().requestRefresh(activeAgentId);
3211
3145
  }
3212
3146
  suppressAssistantTextForToolOnlyRef.current = false;
3213
- currentAssistantMessageRef.current = null;
3214
3147
  abortControllerRef.current = null;
3215
3148
  }
3216
3149
  }, [
@@ -3228,6 +3161,9 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3228
3161
  noteThreadStarted,
3229
3162
  chatText,
3230
3163
  uploadToConversation,
3164
+ changeConversation,
3165
+ changeLayer,
3166
+ reduce,
3231
3167
  ]);
3232
3168
  // Send pending prompt once history loaded and adapter/handler available
3233
3169
  useEffect(() => {
@@ -3290,28 +3226,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3290
3226
  void adapter.terminateRequest().catch(() => { });
3291
3227
  }
3292
3228
  }
3293
- // Mark in-flight tool calls as interrupted so UI doesn't remain "Executing".
3294
- for (const [toolCallId, toolCall] of toolCallsRef.current.entries()) {
3295
- if (toolCall.status === 'executing' || toolCall.status === 'inProgress') {
3296
- toolCallsRef.current.set(toolCallId, {
3297
- ...toolCall,
3298
- status: 'error',
3299
- error: 'Interrupted by user',
3300
- });
3301
- }
3302
- }
3303
- setDisplayItems(prev => prev.map(item => {
3304
- if (!isToolCallMessage(item))
3305
- return item;
3306
- if (item.status !== 'executing' && item.status !== 'inProgress') {
3307
- return item;
3308
- }
3309
- return {
3310
- ...item,
3311
- status: 'error',
3312
- error: 'Interrupted by user',
3313
- };
3314
- }));
3229
+ // The turn ends stopped: what streamed stops, a call under way says so.
3230
+ changeConversation(state => withTurnEnded(state, 'stopped'));
3315
3231
  if (useStoreMode) {
3316
3232
  useChatStore.getState().stopStreaming();
3317
3233
  }
@@ -3324,7 +3240,6 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3324
3240
  // own stop event arrives — if it ever does.
3325
3241
  agentRuntimeStore.getState().stopSubagentActivity();
3326
3242
  suppressAssistantTextForToolOnlyRef.current = false;
3327
- currentAssistantMessageRef.current = null;
3328
3243
  // Also interrupt any code running in the sandbox (best-effort).
3329
3244
  sandboxStatusQuery.interrupt();
3330
3245
  /*
@@ -3384,8 +3299,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3384
3299
  }, [handleSend]);
3385
3300
  // ---- handleNewChat ----
3386
3301
  const handleNewChat = useCallback(() => {
3387
- setDisplayItems([]);
3388
- toolCallsRef.current.clear();
3302
+ resetConversation();
3389
3303
  pendingToolExecutionsRef.current = 0;
3390
3304
  setInput('');
3391
3305
  threadIdRef.current = generateMessageId();
@@ -3395,7 +3309,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3395
3309
  useConversationStore.getState().clearMessages(historyScopeId);
3396
3310
  onNewChat?.();
3397
3311
  headerButtons?.onNewChat?.();
3398
- }, [clearStoreMessages, onNewChat, headerButtons, useStoreMode, runtimeId]);
3312
+ }, [
3313
+ clearStoreMessages,
3314
+ onNewChat,
3315
+ headerButtons,
3316
+ useStoreMode,
3317
+ runtimeId,
3318
+ resetConversation,
3319
+ ]);
3399
3320
  // Hand the send function to a host that owns the input box (the LOOP
3400
3321
  // workspace). Withdrawn on unmount so nothing holds a stale sender.
3401
3322
  useEffect(() => {
@@ -3440,8 +3361,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3440
3361
  // ---- handleClear ----
3441
3362
  const handleClear = useCallback(() => {
3442
3363
  if (window.confirm('Clear all messages?')) {
3443
- setDisplayItems([]);
3444
- toolCallsRef.current.clear();
3364
+ resetConversation();
3445
3365
  if (useStoreMode)
3446
3366
  clearStoreMessages();
3447
3367
  if (historyScopeId)
@@ -3449,22 +3369,32 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3449
3369
  onClear?.();
3450
3370
  headerButtons?.onClear?.();
3451
3371
  }
3452
- }, [clearStoreMessages, onClear, headerButtons, useStoreMode, runtimeId]);
3372
+ }, [
3373
+ clearStoreMessages,
3374
+ onClear,
3375
+ headerButtons,
3376
+ useStoreMode,
3377
+ runtimeId,
3378
+ resetConversation,
3379
+ ]);
3453
3380
  // ---- Turn removal (the TurnFooter's remove action) ----
3454
3381
  const handleRemoveItems = useCallback((ids) => {
3455
3382
  const drop = new Set(ids);
3456
- setDisplayItems(prev => prev.filter(item => !drop.has(item.id)));
3457
- // The tool-call registry mirrors the transcript; a removed turn's calls
3458
- // must not linger there or a late protocol event would resurrect them.
3459
- for (const [toolCallId, toolCall] of toolCallsRef.current.entries()) {
3460
- if (drop.has(toolCall.id)) {
3461
- toolCallsRef.current.delete(toolCallId);
3383
+ // The turn's rows leave the conversation, its steps the web's layer.
3384
+ const rows = displayItemsRef.current
3385
+ .filter(item => drop.has(item.id))
3386
+ .map(rowIdOf);
3387
+ changeConversation(state => withoutItems(state, rows));
3388
+ changeLayer(kept => kept.steps.some(step => drop.has(step.item.id))
3389
+ ? {
3390
+ ...kept,
3391
+ steps: kept.steps.filter(step => !drop.has(step.item.id)),
3462
3392
  }
3463
- }
3464
- }, []);
3393
+ : kept);
3394
+ }, [changeConversation, changeLayer]);
3465
3395
  // ---- HITL respond handler (passed to MessageList) ----
3466
3396
  const handleRespond = useCallback(async (toolCallId, result) => {
3467
- const existingToolCall = toolCallsRef.current.get(toolCallId);
3397
+ const existingToolCall = toolCardNow(toolCallId);
3468
3398
  if (existingToolCall &&
3469
3399
  (existingToolCall.status === 'executing' ||
3470
3400
  existingToolCall.status === 'inProgress')) {
@@ -3537,16 +3467,17 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3537
3467
  if (isMcpApprovalTool && !requiresClientContinuation) {
3538
3468
  return;
3539
3469
  }
3540
- const updatedToolCall = {
3541
- ...existingToolCall,
3470
+ // The person's decision, said on the call; the run goes on.
3471
+ changeConversation(state => reduceConversation(reduceConversation(state, {
3472
+ type: 'TOOL_CALL_DONE',
3473
+ toolCallId,
3474
+ success: approved,
3542
3475
  result,
3543
- status: approved ? 'complete' : 'error',
3544
- error: approved ? undefined : 'Tool approval rejected by user',
3545
- };
3546
- toolCallsRef.current.set(toolCallId, updatedToolCall);
3547
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) && item.toolCallId === toolCallId
3548
- ? updatedToolCall
3549
- : item));
3476
+ answered: true,
3477
+ ...(approved
3478
+ ? {}
3479
+ : { error: 'Tool approval rejected by user' }),
3480
+ }), { type: 'RUN_STARTED' }));
3550
3481
  setIsLoading(true);
3551
3482
  setIsStreaming(true);
3552
3483
  await adapterRef.current.sendToolResult(toolCallId, {
@@ -3572,15 +3503,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3572
3503
  }
3573
3504
  return;
3574
3505
  }
3575
- const updatedToolCall = {
3576
- ...existingToolCall,
3506
+ // The person's answer completes the call.
3507
+ reduce({
3508
+ type: 'TOOL_CALL_DONE',
3509
+ toolCallId,
3510
+ success: true,
3577
3511
  result,
3578
- status: 'complete',
3579
- };
3580
- toolCallsRef.current.set(toolCallId, updatedToolCall);
3581
- setDisplayItems(prev => prev.map(item => isToolCallMessage(item) && item.toolCallId === toolCallId
3582
- ? updatedToolCall
3583
- : item));
3512
+ answered: true,
3513
+ });
3584
3514
  if (adapterRef.current) {
3585
3515
  let responseText;
3586
3516
  if (typeof result === 'string') {
@@ -3612,6 +3542,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3612
3542
  };
3613
3543
  setIsLoading(true);
3614
3544
  setIsStreaming(true);
3545
+ reduce({ type: 'RUN_STARTED' });
3615
3546
  try {
3616
3547
  const allMessages = displayItems.filter((item) => !isToolCallMessage(item));
3617
3548
  await adapterRef.current.sendMessage(userMessage, {
@@ -3626,7 +3557,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3626
3557
  // event will handle it when the run truly completes.
3627
3558
  }
3628
3559
  }
3629
- }, [displayItems, onApproveApproval, onRejectApproval]);
3560
+ }, [
3561
+ displayItems,
3562
+ onApproveApproval,
3563
+ onRejectApproval,
3564
+ toolCardNow,
3565
+ changeConversation,
3566
+ reduce,
3567
+ ]);
3630
3568
  handleRespondRef.current = handleRespond;
3631
3569
  // ---- Suggestion handlers (for EmptyState) ----
3632
3570
  const handleSuggestionSubmit = useCallback((suggestion) => {