@cap-js/agents 0.9.6 → 0.9.8

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 (83) hide show
  1. package/README.md +2 -131
  2. package/_i18n/messages.properties +2 -0
  3. package/_i18n/messages_ar.properties +48 -0
  4. package/_i18n/messages_bg.properties +48 -0
  5. package/_i18n/messages_cs.properties +48 -0
  6. package/_i18n/messages_da.properties +48 -0
  7. package/_i18n/messages_de.properties +48 -0
  8. package/_i18n/messages_el.properties +48 -0
  9. package/_i18n/messages_en.properties +48 -0
  10. package/_i18n/messages_es.properties +48 -0
  11. package/_i18n/messages_es_MX.properties +48 -0
  12. package/_i18n/messages_fi.properties +48 -0
  13. package/_i18n/messages_fr.properties +48 -0
  14. package/_i18n/messages_he.properties +48 -0
  15. package/_i18n/messages_hr.properties +48 -0
  16. package/_i18n/messages_hu.properties +48 -0
  17. package/_i18n/messages_it.properties +48 -0
  18. package/_i18n/messages_ja.properties +48 -0
  19. package/_i18n/messages_kk.properties +48 -0
  20. package/_i18n/messages_ko.properties +48 -0
  21. package/_i18n/messages_ms.properties +48 -0
  22. package/_i18n/messages_nl.properties +48 -0
  23. package/_i18n/messages_no.properties +48 -0
  24. package/_i18n/messages_pl.properties +48 -0
  25. package/_i18n/messages_pt.properties +48 -0
  26. package/_i18n/messages_ro.properties +48 -0
  27. package/_i18n/messages_ru.properties +48 -0
  28. package/_i18n/messages_sh.properties +48 -0
  29. package/_i18n/messages_sk.properties +48 -0
  30. package/_i18n/messages_sl.properties +48 -0
  31. package/_i18n/messages_sv.properties +48 -0
  32. package/_i18n/messages_th.properties +48 -0
  33. package/_i18n/messages_tr.properties +48 -0
  34. package/_i18n/messages_uk.properties +48 -0
  35. package/_i18n/messages_vi.properties +48 -0
  36. package/_i18n/messages_zh_CN.properties +48 -0
  37. package/_i18n/messages_zh_TW.properties +48 -0
  38. package/cds-plugin.js +15 -10
  39. package/lib/agents/middleware/content-filter.js +4 -0
  40. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
  41. package/lib/agents/middleware/hitl.js +17 -6
  42. package/lib/agents/middleware/index.js +3 -1
  43. package/lib/agents/middleware/masking.js +127 -0
  44. package/lib/agents/middleware/remote-mcp.js +10 -5
  45. package/lib/agents/middleware/status-update.js +209 -76
  46. package/lib/agents/middleware/tool-wrap.js +1 -2
  47. package/lib/agents/summarize-on-timeout.js +5 -2
  48. package/lib/config/local.js +121 -8
  49. package/lib/eval/eval-run.js +50 -4
  50. package/lib/masking/index.js +90 -0
  51. package/lib/masking/store.js +80 -0
  52. package/lib/masking/structured/findElements.js +459 -0
  53. package/lib/masking/structured/index.js +170 -0
  54. package/lib/masking/unstructured/dpi.js +84 -0
  55. package/lib/masking/unstructured/hana.js +72 -0
  56. package/lib/masking/unstructured/index.js +52 -0
  57. package/lib/models/aicore.js +16 -3
  58. package/lib/models/openai.js +9 -0
  59. package/lib/preview/chat.html +623 -119
  60. package/lib/protocol/persistence/cleanup.js +13 -4
  61. package/lib/telemetry/chat-tracing.js +9 -2
  62. package/lib/telemetry/mlflow/evaluation.js +83 -4
  63. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
  64. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
  65. package/lib/telemetry/mlflow/index.js +1 -7
  66. package/lib/telemetry/mlflow/prompts.js +15 -11
  67. package/lib/telemetry/mlflow/tracing.js +3 -3
  68. package/lib/telemetry/span-masking.js +165 -0
  69. package/lib/telemetry/tool-tracing.js +4 -4
  70. package/lib/utils/markdown.js +3 -10
  71. package/lib/utils/resilience.js +2 -1
  72. package/lib/utils/toml.js +67 -0
  73. package/lib/utils/usage.js +34 -0
  74. package/lib/utils/utils.js +43 -1
  75. package/package.json +33 -26
  76. package/srv/handlers/graph-executor/crash-handler.js +47 -0
  77. package/srv/handlers/graph-executor/hitl.js +27 -0
  78. package/srv/handlers/graph-executor.js +69 -20
  79. package/srv/handlers/index.js +6 -1
  80. package/srv/handlers/mcp-tools.js +11 -2
  81. package/srv/handlers/subagent-tools.js +13 -4
  82. package/srv/handlers/system-prompt.js +30 -20
  83. package/srv/handlers/tools.js +14 -13
@@ -26,6 +26,7 @@
26
26
  align-items: center;
27
27
  gap: 12px;
28
28
  flex-shrink: 0;
29
+ position: relative;
29
30
  }
30
31
  header h1 {
31
32
  font-size: 1rem;
@@ -74,11 +75,15 @@
74
75
  }
75
76
 
76
77
  .msg,
77
- .thinking-summary {
78
+ .turn-panel {
78
79
  max-width: 75%;
79
80
  box-sizing: border-box;
80
81
  }
81
82
 
83
+ .msg-wrap .msg {
84
+ max-width: 100%;
85
+ }
86
+
82
87
  .msg {
83
88
  padding: 10px 14px;
84
89
  border-radius: 12px;
@@ -96,6 +101,16 @@
96
101
  border-bottom-right-radius: 3px;
97
102
  white-space: pre-wrap;
98
103
  }
104
+ .msg.user.queued {
105
+ opacity: 0.65;
106
+ }
107
+ .msg.user.queued::after {
108
+ content: "Queued";
109
+ display: block;
110
+ margin-top: 4px;
111
+ font-size: 0.7em;
112
+ text-align: right;
113
+ }
99
114
  .msg.agent {
100
115
  align-self: flex-start;
101
116
  background: #d0e9ff;
@@ -246,6 +261,7 @@
246
261
 
247
262
  #prompt {
248
263
  flex: 1;
264
+ box-sizing: border-box;
249
265
  padding: 10px 14px;
250
266
  border: 1px solid #91adc5;
251
267
  border-radius: 8px;
@@ -288,7 +304,6 @@
288
304
  #send.cancel:hover {
289
305
  background: #c62828;
290
306
  }
291
-
292
307
  .approval-actions {
293
308
  display: flex;
294
309
  gap: 8px;
@@ -400,7 +415,7 @@
400
415
 
401
416
  /* Thinking messages are streamed into an expanded <details> panel,
402
417
  then collapsed once the final response arrives. */
403
- .thinking-summary {
418
+ .turn-panel {
404
419
  align-self: flex-start;
405
420
  margin: 4px 0 8px 0;
406
421
  font-size: 0.88em;
@@ -409,17 +424,17 @@
409
424
  padding-left: 10px;
410
425
  overflow-wrap: anywhere;
411
426
  }
412
- .thinking-summary summary {
427
+ .turn-panel summary {
413
428
  cursor: pointer;
414
429
  user-select: none;
415
430
  color: #888;
416
431
  list-style: none;
417
432
  }
418
- .thinking-summary summary::before {
433
+ .turn-panel > summary::before {
419
434
  content: "▶ ";
420
435
  font-size: 0.8em;
421
436
  }
422
- .thinking-summary[open] summary::before {
437
+ .turn-panel[open] > summary::before {
423
438
  content: "▼ ";
424
439
  }
425
440
  .thinking-step {
@@ -428,27 +443,231 @@
428
443
  font-style: italic;
429
444
  overflow-x: auto;
430
445
  }
431
- .thinking-step + .thinking-step {
446
+ .thinking-step p {
447
+ margin: 0;
448
+ }
449
+ .thinking-step + .thinking-step,
450
+ .thinking-step + .tool-call-item,
451
+ .tool-call-item + .thinking-step,
452
+ .tool-call-item + .tool-call-item {
432
453
  border-top: 1px dashed #e0e0e0;
433
454
  margin-top: 4px;
434
455
  }
435
456
  @media (prefers-reduced-motion: no-preference) {
436
- .thinking-summary {
457
+ .turn-panel {
437
458
  transition: height 0.16s ease;
438
459
  }
439
460
  }
440
461
  @media (prefers-color-scheme: dark) {
441
- .thinking-summary {
462
+ .turn-panel {
442
463
  color: #999;
443
464
  border-left-color: #444;
444
465
  }
445
- .thinking-summary summary {
466
+ .turn-panel summary {
446
467
  color: #aaa;
447
468
  }
448
- .thinking-step + .thinking-step {
469
+ .thinking-step + .thinking-step,
470
+ .thinking-step + .tool-call-item,
471
+ .tool-call-item + .thinking-step,
472
+ .tool-call-item + .tool-call-item {
449
473
  border-top-color: #333;
450
474
  }
451
475
  }
476
+ .tool-call-item {
477
+ padding: 2px 0;
478
+ list-style: none;
479
+ }
480
+ .tool-call-item summary {
481
+ cursor: pointer;
482
+ user-select: none;
483
+ list-style: none;
484
+ display: flex;
485
+ align-items: baseline;
486
+ gap: 4px;
487
+ padding: 2px 0;
488
+ }
489
+ .tool-call-item > summary::before {
490
+ content: "▶";
491
+ font-size: 0.7em;
492
+ color: #aaa;
493
+ flex-shrink: 0;
494
+ }
495
+ .tool-call-item[open] > summary::before {
496
+ content: "▼";
497
+ }
498
+ .tool-call-item:not([hasdetails]) > summary::before {
499
+ visibility: hidden;
500
+ }
501
+ .tool-call-status {
502
+ font-size: 0.85em;
503
+ }
504
+ .tool-call-label {
505
+ font-weight: 500;
506
+ }
507
+ .tool-call-kind {
508
+ font-size: 0.78em;
509
+ padding: 1px 5px;
510
+ border-radius: 8px;
511
+ background: #eee;
512
+ color: #555;
513
+ }
514
+ .tool-call-kind.kind-query {
515
+ background: #dbeafe;
516
+ color: #1d4ed8;
517
+ }
518
+ .tool-call-kind.kind-call {
519
+ background: #fef3c7;
520
+ color: #92400e;
521
+ }
522
+ .tool-call-kind.kind-agent {
523
+ background: #ede9fe;
524
+ color: #5b21b6;
525
+ }
526
+ .tool-call-detail {
527
+ margin: 4px 0 2px 12px;
528
+ padding: 4px 8px;
529
+ background: #f9f9f9;
530
+ border-radius: 4px;
531
+ font-family: monospace;
532
+ font-size: 0.85em;
533
+ white-space: pre-wrap;
534
+ overflow-x: auto;
535
+ max-height: 200px;
536
+ overflow-y: auto;
537
+ }
538
+ .tool-call-detail-label {
539
+ font-size: 0.75em;
540
+ color: #999;
541
+ margin: 6px 0 2px 12px;
542
+ text-transform: uppercase;
543
+ letter-spacing: 0.04em;
544
+ }
545
+ @media (prefers-color-scheme: dark) {
546
+ .tool-call-item > summary::before {
547
+ color: #555;
548
+ }
549
+ .tool-call-kind {
550
+ background: #333;
551
+ color: #aaa;
552
+ }
553
+ .tool-call-kind.kind-query {
554
+ background: #1e3a5f;
555
+ color: #93c5fd;
556
+ }
557
+ .tool-call-kind.kind-call {
558
+ background: #451a03;
559
+ color: #fcd34d;
560
+ }
561
+ .tool-call-kind.kind-agent {
562
+ background: #2e1065;
563
+ color: #c4b5fd;
564
+ }
565
+ .tool-call-detail {
566
+ background: #1a1a1a;
567
+ color: #ccc;
568
+ }
569
+ .tool-call-detail-label {
570
+ color: #555;
571
+ }
572
+ }
573
+ /* Subtle per-message metadata: context size + response time */
574
+ .msg-wrap {
575
+ display: flex;
576
+ flex-direction: column;
577
+ align-self: flex-start;
578
+ max-width: 75%;
579
+ }
580
+ .msg-meta {
581
+ display: inline-block;
582
+ align-self: flex-end;
583
+ margin-top: 3px;
584
+ font-size: 10px;
585
+ cursor: default;
586
+ position: relative;
587
+ white-space: nowrap;
588
+ color: rgba(83, 116, 146, 0.5);
589
+ }
590
+ .msg-meta:hover {
591
+ color: rgba(83, 116, 146, 0.85);
592
+ }
593
+ /* Hover tooltip with full token breakdown */
594
+ .msg-meta-tooltip {
595
+ display: none;
596
+ position: absolute;
597
+ bottom: calc(100% + 6px);
598
+ right: 0;
599
+ background: rgba(42, 61, 82, 0.72);
600
+ color: #e8f2fa;
601
+ font-size: 11px;
602
+ border-radius: 6px;
603
+ padding: 7px 9px;
604
+ z-index: 100;
605
+ pointer-events: none;
606
+ border: 1px solid rgba(255, 255, 255, 0.14);
607
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
608
+ backdrop-filter: blur(8px);
609
+ -webkit-backdrop-filter: blur(8px);
610
+ text-align: left;
611
+ }
612
+ .msg-meta-tooltip-grid {
613
+ display: grid;
614
+ grid-template-columns: auto auto;
615
+ column-gap: 16px;
616
+ row-gap: 2px;
617
+ white-space: nowrap;
618
+ }
619
+ .msg-meta-tooltip-grid .tt-indent {
620
+ padding-left: 12px;
621
+ }
622
+ .msg-meta-tooltip-grid .tt-val {
623
+ text-align: right;
624
+ }
625
+ .msg-meta:hover .msg-meta-tooltip {
626
+ display: block;
627
+ }
628
+ /* Total context indicator: right-aligned row just above the input bar */
629
+ #context-indicator-row {
630
+ display: none;
631
+ padding: 2px 20px 2px;
632
+ text-align: right;
633
+ flex-shrink: 0;
634
+ }
635
+ #context-indicator-row.visible {
636
+ display: block;
637
+ }
638
+ #context-indicator {
639
+ font-size: 10px;
640
+ opacity: 0.45;
641
+ white-space: nowrap;
642
+ color: #537492;
643
+ position: relative;
644
+ cursor: default;
645
+ transition: opacity 0.15s;
646
+ }
647
+ #context-indicator:hover {
648
+ opacity: 0.85;
649
+ }
650
+ /* Hover tooltip mirroring per-message breakdown, anchored to the indicator */
651
+ #context-indicator:hover .msg-meta-tooltip {
652
+ display: block;
653
+ }
654
+ @media (prefers-color-scheme: dark) {
655
+ .msg-meta {
656
+ color: rgba(145, 173, 197, 0.55);
657
+ }
658
+ .msg-meta:hover {
659
+ color: rgba(145, 173, 197, 0.9);
660
+ }
661
+ .msg-meta-tooltip {
662
+ background: rgba(10, 30, 48, 0.72);
663
+ color: #c8dff0;
664
+ border-color: rgba(255, 255, 255, 0.12);
665
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
666
+ }
667
+ #context-indicator {
668
+ color: #91adc5;
669
+ }
670
+ }
452
671
  </style>
453
672
  <script src="marked.min.js"></script>
454
673
  </head>
@@ -464,6 +683,12 @@
464
683
  <div class="status-text" id="status-text"></div>
465
684
  </div>
466
685
 
686
+ <div id="context-indicator-row">
687
+ <span id="context-indicator"
688
+ ><span class="msg-meta-tooltip" id="context-indicator-tooltip"></span
689
+ ></span>
690
+ </div>
691
+
467
692
  <div id="input-row">
468
693
  <textarea id="prompt" rows="1" placeholder="Message {{agentName}}…" autofocus></textarea>
469
694
  <button id="send">Send</button>
@@ -508,21 +733,156 @@
508
733
  const THINKING_SUMMARY_ANIMATION_MS = 160
509
734
 
510
735
  let busy = false
736
+ let processingQueue = false
737
+ const messageQueue = []
511
738
  let contextId = null
512
739
  let pendingTaskId = null
513
740
  let activeTaskId = null
514
741
  let activeAbort = null
515
742
  let streamingBubble = null
743
+ let lastStreamingBubble = null
516
744
  let streamingText = ""
517
745
  let thinkingFinalized = false
518
- let thinkingStepEls = [] // thinking steps inside the summary panel
519
- let thinkingTexts = [] // accumulated text per thinking step
520
- let thinkingStepCount = 0
521
- let thinkingSummaryEl = null // <details> containing thinking steps
522
- let currentThinkingArtifactId = null // artifactId of open thinking step
523
- let currentThinkingClosed = false // true once lastChunk seen for current id
746
+ let thinkingStepEls = []
747
+ let thinkingTexts = []
748
+ let turnPanelEl = null // single <details> for thinking steps + tool calls
524
749
  let pendingThinkingUpdate = null
525
750
  let pendingThinkingTimer = null
751
+ let toolCallsMap = new Map() // tool call id → { el, data }
752
+
753
+ // Token accounting. The server sends cumulative token counts (a running sum
754
+ // over every model call in the conversation) plus context_tokens — the prompt
755
+ // size of the most recent model call, i.e. the current live context occupancy.
756
+ // Every field except context_tokens is cumulative, so per-turn values are
757
+ // derived by diffing against the previous turn.
758
+ let contextTokens = 0 // current live context size (last-call input incl. cache)
759
+ let prevContextTokens = 0 // context size after the previous turn
760
+ // Cumulative counts as of the previous turn, for per-turn deltas.
761
+ let prev = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, reasoning: 0 }
762
+ let responseStartTime = null
763
+ const contextIndicator = document.getElementById("context-indicator")
764
+ const contextIndicatorTooltip = document.getElementById("context-indicator-tooltip")
765
+
766
+ function fmt(n) {
767
+ if (n >= 1000000) return (n / 1000000).toFixed(1) + "M"
768
+ if (n >= 1000) return (n / 1000).toFixed(1) + "k"
769
+ return String(n)
770
+ }
771
+
772
+ function fmtDuration(ms) {
773
+ if (ms < 1000) return ms + "ms"
774
+ return (ms / 1000).toFixed(1) + "s"
775
+ }
776
+
777
+ function buildTooltipTable(rows) {
778
+ const grid = document.createElement("div")
779
+ grid.className = "msg-meta-tooltip-grid"
780
+ for (const row of rows) {
781
+ const label = document.createElement("div")
782
+ if (row.indent) label.className = "tt-indent"
783
+ label.textContent = row.label
784
+ const val = document.createElement("div")
785
+ val.className = "tt-val"
786
+ val.textContent = row.value
787
+ grid.appendChild(label)
788
+ grid.appendChild(val)
789
+ }
790
+ return grid
791
+ }
792
+
793
+ function buildTokenRows(contextVal, input, cacheRead, cacheCreate, output, reasoning) {
794
+ const rows = [{ label: "Context", value: contextVal }]
795
+ if (input > 0) {
796
+ rows.push({ label: "Input", value: fmt(input) })
797
+ if (cacheRead > 0) rows.push({ label: "Cache read", value: fmt(cacheRead), indent: true })
798
+ if (cacheCreate > 0)
799
+ rows.push({ label: "Cache write", value: fmt(cacheCreate), indent: true })
800
+ }
801
+ if (output > 0)
802
+ rows.push({
803
+ label: "Output" + (reasoning > 0 ? " (reasoning " + fmt(reasoning) + ")" : ""),
804
+ value: fmt(output),
805
+ })
806
+ return rows
807
+ }
808
+
809
+ function updateContextIndicator() {
810
+ if (contextTokens === 0) return
811
+ contextIndicator.childNodes[0].nodeType === Node.TEXT_NODE
812
+ ? (contextIndicator.childNodes[0].textContent = "⛁ " + fmt(contextTokens))
813
+ : contextIndicator.insertBefore(
814
+ document.createTextNode("⛁ " + fmt(contextTokens)),
815
+ contextIndicatorTooltip,
816
+ )
817
+ contextIndicatorTooltip.innerHTML = ""
818
+ contextIndicatorTooltip.appendChild(
819
+ buildTooltipTable(
820
+ buildTokenRows(
821
+ fmt(contextTokens),
822
+ prev.input,
823
+ prev.cacheRead,
824
+ prev.cacheCreate,
825
+ prev.output,
826
+ prev.reasoning,
827
+ ),
828
+ ),
829
+ )
830
+ document.getElementById("context-indicator-row").classList.add("visible")
831
+ }
832
+
833
+ function attachMsgMeta(el, usage, durationMs) {
834
+ // All these fields are cumulative across the conversation; diff against the
835
+ // previous turn to get this turn's billing-relevant amounts. input_tokens
836
+ // already includes cache-read/cache-write tokens, so the uncached (full-rate)
837
+ // input is input minus those.
838
+ const stepInTotal = (usage.input_tokens || 0) - prev.input
839
+ const stepOut = (usage.output_tokens || 0) - prev.output
840
+ const stepCacheRead = (usage.cache_read_input_tokens || 0) - prev.cacheRead
841
+ const stepCacheCreate = (usage.cache_creation_input_tokens || 0) - prev.cacheCreate
842
+ const stepReasoning = (usage.reasoning_tokens || 0) - prev.reasoning
843
+ const newContext = usage.context_tokens || usage.input_tokens || 0
844
+
845
+ // Context growth: how much this exchange added to the live context window.
846
+ const contextGrowth = newContext - prevContextTokens
847
+
848
+ prevContextTokens = newContext
849
+ contextTokens = newContext
850
+ prev = {
851
+ input: usage.input_tokens || 0,
852
+ output: usage.output_tokens || 0,
853
+ cacheRead: usage.cache_read_input_tokens || 0,
854
+ cacheCreate: usage.cache_creation_input_tokens || 0,
855
+ reasoning: usage.reasoning_tokens || 0,
856
+ }
857
+ updateContextIndicator()
858
+
859
+ const meta = document.createElement("span")
860
+ meta.className = "msg-meta"
861
+
862
+ const summary =
863
+ "⛁ " + fmt(contextGrowth) + (durationMs != null ? " · " + fmtDuration(durationMs) : "")
864
+ meta.appendChild(document.createTextNode(summary))
865
+
866
+ // Hover tooltip: per-turn, billing-relevant token breakdown.
867
+ const tooltip = document.createElement("span")
868
+ tooltip.className = "msg-meta-tooltip"
869
+ const growthLabel = (contextGrowth >= 0 ? "+" : "") + fmt(contextGrowth)
870
+ tooltip.appendChild(
871
+ buildTooltipTable(
872
+ buildTokenRows(
873
+ fmt(contextTokens) + " (" + growthLabel + ")",
874
+ stepInTotal,
875
+ stepCacheRead,
876
+ stepCacheCreate,
877
+ stepOut,
878
+ stepReasoning,
879
+ ),
880
+ ),
881
+ )
882
+ meta.appendChild(tooltip)
883
+
884
+ ;(el._wrap ?? el).appendChild(meta)
885
+ }
526
886
 
527
887
  // Auto-enable streaming when agent card advertises streaming capability
528
888
  fetch(AGENT_URL + ".well-known/agent-card.json", { headers: authHeaders() })
@@ -547,13 +907,14 @@
547
907
 
548
908
  prompt.addEventListener("input", () => {
549
909
  prompt.style.height = "auto"
550
- prompt.style.height = Math.min(prompt.scrollHeight, 140) + "px"
910
+ const borderHeight = prompt.offsetHeight - prompt.clientHeight
911
+ prompt.style.height = Math.min(prompt.scrollHeight + borderHeight, 140) + "px"
912
+ renderComposerAction()
551
913
  })
552
914
  prompt.addEventListener("keydown", (e) => {
553
915
  if (e.key === "Enter" && !e.shiftKey) {
554
916
  e.preventDefault()
555
- if (busy) cancel()
556
- else send()
917
+ enqueueMessage()
557
918
  } else if (e.key === "ArrowUp" && prompt.selectionStart === 0) {
558
919
  e.preventDefault()
559
920
  if (historyIndex === -1) currentDraft = prompt.value
@@ -561,6 +922,7 @@
561
922
  historyIndex++
562
923
  prompt.value = inputHistory[inputHistory.length - 1 - historyIndex]
563
924
  prompt.selectionStart = prompt.selectionEnd = prompt.value.length
925
+ renderComposerAction()
564
926
  }
565
927
  } else if (e.key === "ArrowDown" && prompt.selectionEnd === prompt.value.length) {
566
928
  e.preventDefault()
@@ -568,55 +930,65 @@
568
930
  historyIndex--
569
931
  prompt.value = inputHistory[inputHistory.length - 1 - historyIndex]
570
932
  prompt.selectionStart = prompt.selectionEnd = prompt.value.length
933
+ renderComposerAction()
571
934
  } else if (historyIndex === 0) {
572
935
  historyIndex = -1
573
936
  prompt.value = currentDraft
574
937
  prompt.selectionStart = prompt.selectionEnd = prompt.value.length
938
+ renderComposerAction()
575
939
  }
576
940
  }
577
941
  })
578
942
  sendBtn.addEventListener("click", () => {
579
- if (busy) cancel()
580
- else send()
943
+ if (busy && !prompt.value.trim()) cancel()
944
+ else enqueueMessage()
581
945
  })
946
+ renderComposerAction()
582
947
 
583
- function addMessage(role, text) {
948
+ function addMessage(role, text, beforeEl) {
584
949
  const el = document.createElement("div")
585
950
  el.className = "msg " + role
586
951
  if (role === "agent" || role === "agent_thinking")
587
952
  el.innerHTML = md(text, { sanitize: true })
588
953
  else el.textContent = text
589
954
  if (role === "agent_thinking") el.className = "msg agent_thinking"
590
- messages.insertBefore(el, typing.parentElement === messages ? typing : statusText)
955
+ const anchor = beforeEl ?? (typing.parentElement === messages ? typing : statusText)
956
+ if (role === "agent") {
957
+ const wrap = document.createElement("div")
958
+ wrap.className = "msg-wrap"
959
+ wrap.appendChild(el)
960
+ el._wrap = wrap
961
+ messages.insertBefore(wrap, anchor)
962
+ } else {
963
+ messages.insertBefore(el, anchor)
964
+ }
591
965
  messages.scrollTop = messages.scrollHeight
592
966
  return el
593
967
  }
594
968
 
595
- function updateThinkingSummaryLabel() {
596
- if (!thinkingSummaryEl) return
597
- const summary = thinkingSummaryEl.querySelector("summary")
598
- if (!summary) return
599
- const count = thinkingStepCount
600
- summary.textContent = `Thinking (${count} step${count === 1 ? "" : "s"})`
969
+ function updateTurnPanelLabel() {
970
+ if (!turnPanelEl) return
971
+ const n = thinkingStepEls.length + toolCallsMap.size
972
+ turnPanelEl.querySelector("summary").textContent = `Steps (${n})`
601
973
  }
602
974
 
603
- function ensureThinkingSummary() {
604
- if (!thinkingSummaryEl) {
605
- thinkingSummaryEl = document.createElement("details")
606
- thinkingSummaryEl.className = "thinking-summary"
607
- thinkingSummaryEl.open = true
975
+ function ensureTurnPanel() {
976
+ if (!turnPanelEl) {
977
+ turnPanelEl = document.createElement("details")
978
+ turnPanelEl.className = "turn-panel"
979
+ turnPanelEl.open = true
608
980
  const summary = document.createElement("summary")
609
- summary.addEventListener("click", toggleThinkingSummary)
610
- thinkingSummaryEl.appendChild(summary)
611
- updateThinkingSummaryLabel()
981
+ summary.addEventListener("click", toggleTurnPanel)
982
+ turnPanelEl.appendChild(summary)
612
983
  messages.insertBefore(
613
- thinkingSummaryEl,
984
+ turnPanelEl,
614
985
  typing.parentElement === messages ? typing : statusText,
615
986
  )
616
987
  }
617
- cancelThinkingSummaryAnimation(thinkingSummaryEl)
618
- thinkingSummaryEl.open = true
619
- return thinkingSummaryEl
988
+ cancelTurnPanelAnimation(turnPanelEl)
989
+ turnPanelEl.open = true
990
+ updateTurnPanelLabel()
991
+ return turnPanelEl
620
992
  }
621
993
 
622
994
  function clearPendingThinkingUpdate() {
@@ -635,34 +1007,31 @@
635
1007
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
636
1008
  }
637
1009
 
638
- function resetThinkingSummaryAnimation(el) {
1010
+ function resetTurnPanelAnimation(el) {
639
1011
  el.style.height = ""
640
1012
  el.style.overflow = ""
641
1013
  }
642
1014
 
643
- function cancelThinkingSummaryAnimation(el) {
1015
+ function cancelTurnPanelAnimation(el) {
644
1016
  if (!el) return
645
- el._thinkingAnimationId = (el._thinkingAnimationId ?? 0) + 1
646
- resetThinkingSummaryAnimation(el)
1017
+ el._animationId = (el._animationId ?? 0) + 1
1018
+ resetTurnPanelAnimation(el)
647
1019
  }
648
1020
 
649
- function toggleThinkingSummary(event) {
1021
+ function toggleTurnPanel(event) {
650
1022
  if (prefersReducedMotion()) return
651
1023
  event.preventDefault()
652
1024
  const el = event.currentTarget.parentElement
653
- if (el.open) collapseThinkingSummary(el)
654
- else expandThinkingSummary(el)
1025
+ if (el.open) collapseTurnPanel(el)
1026
+ else expandTurnPanel(el)
655
1027
  }
656
1028
 
657
1029
  function renderThinkingStep(update) {
658
- const { artifactId, text, lastChunk, append } = update
659
- const panel = ensureThinkingSummary()
660
- const idx = thinkingStepEls.findIndex((step) => step._artifactId === artifactId)
661
- if (append && idx >= 0) {
662
- thinkingTexts[idx] += text
663
- thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
664
- } else if (artifactId === currentThinkingArtifactId && !currentThinkingClosed) {
665
- thinkingTexts[idx] += "\n" + text
1030
+ const { artifactId, text, append } = update
1031
+ const panel = ensureTurnPanel()
1032
+ const idx = thinkingStepEls.findIndex((s) => s._artifactId === artifactId)
1033
+ if (idx !== -1) {
1034
+ thinkingTexts[idx] += append ? text : "\n" + text
666
1035
  thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
667
1036
  } else {
668
1037
  const step = document.createElement("div")
@@ -672,12 +1041,8 @@
672
1041
  panel.appendChild(step)
673
1042
  thinkingStepEls.push(step)
674
1043
  thinkingTexts.push(text)
675
- thinkingStepCount = thinkingStepEls.length
676
- updateThinkingSummaryLabel()
677
- currentThinkingArtifactId = artifactId
678
- currentThinkingClosed = false
1044
+ updateTurnPanelLabel()
679
1045
  }
680
- if (lastChunk) currentThinkingClosed = true
681
1046
  messages.scrollTop = messages.scrollHeight
682
1047
  }
683
1048
 
@@ -706,24 +1071,112 @@
706
1071
  )
707
1072
  }
708
1073
 
1074
+ function escHtml(str) {
1075
+ return String(str)
1076
+ .replace(/&/g, "&amp;")
1077
+ .replace(/</g, "&lt;")
1078
+ .replace(/>/g, "&gt;")
1079
+ .replace(/"/g, "&quot;")
1080
+ }
1081
+
1082
+ const TOOL_STATUS_ICON = { running: "⏳", done: "✓", error: "✗" }
1083
+ const TOOL_RESULT_TRUNCATE = 500
1084
+
1085
+ function updateToolCall(id, data) {
1086
+ let entry = toolCallsMap.get(id)
1087
+ if (!entry) {
1088
+ const el = document.createElement("details")
1089
+ el.className = "tool-call-item"
1090
+ el.innerHTML =
1091
+ `<summary>` +
1092
+ `<span class="tool-call-kind" hidden></span>` +
1093
+ `<span class="tool-call-label"></span>` +
1094
+ `<span class="tool-call-status"></span> ` +
1095
+ `</summary>` +
1096
+ `<div class="tool-call-detail-label" hidden>Args</div><pre class="tool-call-detail args" hidden></pre>` +
1097
+ `<div class="tool-call-detail-label" hidden>Result</div><pre class="tool-call-detail result" hidden></pre>`
1098
+ entry = { el, data: {} }
1099
+ toolCallsMap.set(id, entry)
1100
+ ensureTurnPanel().appendChild(el)
1101
+ } else {
1102
+ ensureTurnPanel()
1103
+ }
1104
+ Object.assign(entry.data, data)
1105
+ const { el } = entry
1106
+ const d = entry.data
1107
+
1108
+ el.querySelector(".tool-call-status").textContent = TOOL_STATUS_ICON[d.status] ?? "⏳"
1109
+ el.querySelector(".tool-call-label").textContent = d.label ?? d.name ?? ""
1110
+ if (d.kind) {
1111
+ const kindEl = el.querySelector(".tool-call-kind")
1112
+ kindEl.textContent = d.kind
1113
+ kindEl.className = `tool-call-kind kind-${d.kind}`
1114
+ kindEl.hidden = false
1115
+ }
1116
+
1117
+ const argsStr =
1118
+ d.args != null
1119
+ ? typeof d.args === "string"
1120
+ ? d.args
1121
+ : JSON.stringify(d.args, null, 2)
1122
+ : null
1123
+ let resultStr = null
1124
+ if (d.result != null) {
1125
+ const raw = typeof d.result === "string" ? d.result : JSON.stringify(d.result, null, 2)
1126
+ resultStr =
1127
+ raw.length > TOOL_RESULT_TRUNCATE
1128
+ ? raw.slice(0, TOOL_RESULT_TRUNCATE) +
1129
+ `\n… (+${raw.length - TOOL_RESULT_TRUNCATE} chars)`
1130
+ : raw
1131
+ }
1132
+
1133
+ if (argsStr != null) {
1134
+ const pre = el.querySelector(".args")
1135
+ pre.textContent = argsStr
1136
+ pre.hidden = false
1137
+ pre.previousElementSibling.hidden = false
1138
+ el.setAttribute("hasdetails", "")
1139
+ }
1140
+ if (resultStr != null) {
1141
+ const pre = el.querySelector(".result")
1142
+ pre.textContent = resultStr
1143
+ pre.hidden = false
1144
+ pre.previousElementSibling.hidden = false
1145
+ el.setAttribute("hasdetails", "")
1146
+ }
1147
+
1148
+ messages.scrollTop = messages.scrollHeight
1149
+ }
1150
+
1151
+ function finalizeTurnPanel() {
1152
+ if (turnPanelEl?.open) {
1153
+ setTimeout(() => {
1154
+ if (turnPanelEl) collapseTurnPanel(turnPanelEl)
1155
+ }, 800)
1156
+ }
1157
+ }
1158
+
709
1159
  function promoteLatestThinkingToResponse(responseText) {
710
- if (!thinkingSummaryEl || !latestThinkingMatches(responseText)) return null
1160
+ if (!turnPanelEl || !latestThinkingMatches(responseText)) return null
711
1161
 
712
1162
  thinkingTexts.pop()
713
1163
  const responseEl = thinkingStepEls.pop()
714
- thinkingStepCount = thinkingStepEls.length
715
1164
 
716
1165
  responseEl.className = "msg agent"
717
1166
  responseEl._promotedFromThinking = true
718
1167
  responseEl.innerHTML = md(responseText)
719
- messages.insertBefore(responseEl, typing.parentElement === messages ? typing : statusText)
720
-
721
- if (thinkingStepEls.length === 0) {
722
- thinkingSummaryEl.remove()
723
- thinkingSummaryEl = null
1168
+ const wrap = document.createElement("div")
1169
+ wrap.className = "msg-wrap"
1170
+ wrap.appendChild(responseEl)
1171
+ responseEl._wrap = wrap
1172
+ messages.insertBefore(wrap, typing.parentElement === messages ? typing : statusText)
1173
+
1174
+ if (thinkingStepEls.length === 0 && toolCallsMap.size === 0) {
1175
+ turnPanelEl.remove()
1176
+ turnPanelEl = null
724
1177
  } else {
725
- updateThinkingSummaryLabel()
726
- collapseThinkingSummary(thinkingSummaryEl)
1178
+ updateTurnPanelLabel()
1179
+ collapseTurnPanel(turnPanelEl)
727
1180
  }
728
1181
 
729
1182
  messages.scrollTop = messages.scrollHeight
@@ -733,28 +1186,27 @@
733
1186
  function finalizeThinking(beforeEl, responseText) {
734
1187
  clearPendingThinkingUpdate()
735
1188
  if (beforeEl?._promotedFromThinking) return
736
- if (thinkingStepEls.length === 0 || !thinkingSummaryEl) return
1189
+ if (thinkingStepEls.length === 0 || !turnPanelEl) return
737
1190
  // Stream labels every model turn as thinking until terminal response is
738
1191
  // known. Its final turn is authoritative response, never a thinking step.
739
1192
  thinkingTexts.pop()
740
1193
  const finalResponse = thinkingStepEls.pop()
741
1194
  finalResponse?.remove()
742
- thinkingStepCount = thinkingStepEls.length
743
- updateThinkingSummaryLabel()
1195
+ updateTurnPanelLabel()
744
1196
 
745
- if (thinkingStepEls.length === 0) {
746
- thinkingSummaryEl.remove()
747
- thinkingSummaryEl = null
1197
+ if (thinkingStepEls.length === 0 && toolCallsMap.size === 0) {
1198
+ turnPanelEl.remove()
1199
+ turnPanelEl = null
748
1200
  return
749
1201
  }
750
1202
 
751
- if (beforeEl && thinkingSummaryEl.nextSibling !== beforeEl) {
752
- messages.insertBefore(thinkingSummaryEl, beforeEl)
1203
+ if (beforeEl && turnPanelEl.nextSibling !== (beforeEl._wrap ?? beforeEl)) {
1204
+ messages.insertBefore(turnPanelEl, beforeEl._wrap ?? beforeEl)
753
1205
  }
754
- collapseThinkingSummary(thinkingSummaryEl)
1206
+ collapseTurnPanel(turnPanelEl)
755
1207
  }
756
1208
 
757
- function collapseThinkingSummary(el) {
1209
+ function collapseTurnPanel(el) {
758
1210
  if (!el || !el.open) return
759
1211
 
760
1212
  if (prefersReducedMotion()) {
@@ -765,20 +1217,20 @@
765
1217
  const summary = el.querySelector("summary")
766
1218
  const startHeight = `${el.offsetHeight}px`
767
1219
  const endHeight = `${summary?.offsetHeight ?? 0}px`
768
- const animationId = (el._thinkingAnimationId ?? 0) + 1
769
- el._thinkingAnimationId = animationId
1220
+ const animationId = (el._animationId ?? 0) + 1
1221
+ el._animationId = animationId
770
1222
  let finished = false
771
1223
 
772
1224
  const finish = () => {
773
1225
  el.removeEventListener("transitionend", finish)
774
- if (el._thinkingAnimationId !== animationId) return
1226
+ if (el._animationId !== animationId) return
775
1227
  if (finished) return
776
1228
  finished = true
777
1229
  el.open = false
778
- resetThinkingSummaryAnimation(el)
1230
+ resetTurnPanelAnimation(el)
779
1231
  }
780
1232
 
781
- resetThinkingSummaryAnimation(el)
1233
+ resetTurnPanelAnimation(el)
782
1234
  el.style.overflow = "hidden"
783
1235
  el.style.height = startHeight
784
1236
  requestAnimationFrame(() => {
@@ -788,7 +1240,7 @@
788
1240
  setTimeout(finish, THINKING_SUMMARY_ANIMATION_MS + 80)
789
1241
  }
790
1242
 
791
- function expandThinkingSummary(el) {
1243
+ function expandTurnPanel(el) {
792
1244
  if (!el || el.open) return
793
1245
 
794
1246
  if (prefersReducedMotion()) {
@@ -800,19 +1252,19 @@
800
1252
  const summary = el.querySelector("summary")
801
1253
  const startHeight = `${summary?.offsetHeight ?? 0}px`
802
1254
  const endHeight = `${el.scrollHeight}px`
803
- const animationId = (el._thinkingAnimationId ?? 0) + 1
804
- el._thinkingAnimationId = animationId
1255
+ const animationId = (el._animationId ?? 0) + 1
1256
+ el._animationId = animationId
805
1257
  let finished = false
806
1258
 
807
1259
  const finish = () => {
808
1260
  el.removeEventListener("transitionend", finish)
809
- if (el._thinkingAnimationId !== animationId) return
1261
+ if (el._animationId !== animationId) return
810
1262
  if (finished) return
811
1263
  finished = true
812
- resetThinkingSummaryAnimation(el)
1264
+ resetTurnPanelAnimation(el)
813
1265
  }
814
1266
 
815
- resetThinkingSummaryAnimation(el)
1267
+ resetTurnPanelAnimation(el)
816
1268
  el.style.overflow = "hidden"
817
1269
  el.style.height = startHeight
818
1270
  requestAnimationFrame(() => {
@@ -869,31 +1321,34 @@
869
1321
 
870
1322
  function setBusy(val) {
871
1323
  busy = val
872
- prompt.disabled = val
873
1324
  if (typing.parentElement !== messages) messages.insertBefore(typing, statusText)
874
1325
  typing.style.display = val ? "block" : "none"
875
1326
  dot.className = "status-dot" + (val ? " busy" : "")
876
- if (val) {
877
- sendBtn.textContent = "Cancel"
878
- sendBtn.classList.add("cancel")
879
- sendBtn.disabled = false
880
- } else {
881
- sendBtn.textContent = "Send"
882
- sendBtn.classList.remove("cancel")
883
- sendBtn.disabled = false
1327
+ if (!val) {
884
1328
  activeTaskId = null
885
1329
  activeAbort = null
886
1330
  statusText.textContent = ""
887
1331
  }
1332
+ renderComposerAction()
888
1333
  messages.scrollTop = messages.scrollHeight
889
1334
  }
890
1335
 
1336
+ function renderComposerAction() {
1337
+ const stopsGeneration = busy && !prompt.value.trim()
1338
+ sendBtn.textContent = stopsGeneration ? "Cancel" : "Send"
1339
+ sendBtn.classList.toggle("cancel", stopsGeneration)
1340
+ sendBtn.setAttribute("aria-label", stopsGeneration ? "Stop generating" : "Send message")
1341
+ sendBtn.title = stopsGeneration ? "Stop generating" : "Send message"
1342
+ sendBtn.disabled = !busy && !prompt.value.trim()
1343
+ }
1344
+
891
1345
  async function resume(decision) {
892
1346
  const taskId = pendingTaskId
893
1347
  pendingTaskId = null
894
1348
 
895
1349
  activeTaskId = taskId
896
1350
  setBusy(true)
1351
+ responseStartTime = performance.now()
897
1352
 
898
1353
  const abort = new AbortController()
899
1354
  activeAbort = abort
@@ -914,6 +1369,7 @@
914
1369
  taskId,
915
1370
  contextId,
916
1371
  parts: [{ kind: "text", text: decision }],
1372
+ metadata: { "tool-status-update": { args: true, result: true } },
917
1373
  },
918
1374
  },
919
1375
  }),
@@ -934,6 +1390,7 @@
934
1390
  } finally {
935
1391
  setBusy(false)
936
1392
  prompt.focus()
1393
+ processQueue()
937
1394
  }
938
1395
  }
939
1396
 
@@ -957,9 +1414,19 @@
957
1414
  return
958
1415
  }
959
1416
 
1417
+ // Token usage: non-streaming embeds in status message metadata; streaming passes via _tokenUsage
1418
+ const tokenUsage =
1419
+ result?._tokenUsage ?? result?.status?.message?.metadata?.["sap.cds.agents.token-usage"]
1420
+ const durationMs =
1421
+ responseStartTime != null ? Math.round(performance.now() - responseStartTime) : null
1422
+
960
1423
  // If streaming already rendered the final text via lastChunk, skip.
961
1424
  if (useStreaming && streamingText !== "") {
962
- if (!thinkingFinalized) finalizeThinking(streamingBubble, streamingText)
1425
+ if (!thinkingFinalized) finalizeThinking(lastStreamingBubble, streamingText)
1426
+ if (tokenUsage && lastStreamingBubble)
1427
+ attachMsgMeta(lastStreamingBubble, tokenUsage, durationMs)
1428
+ finalizeTurnPanel()
1429
+ lastStreamingBubble = null
963
1430
  streamingBubble = null
964
1431
  streamingText = ""
965
1432
  return
@@ -969,6 +1436,7 @@
969
1436
  if (streamingBubble) {
970
1437
  if (streamingText) streamingBubble.innerHTML = md(streamingText)
971
1438
  finalizeThinking(streamingBubble, streamingText)
1439
+ finalizeTurnPanel()
972
1440
  streamingBubble = null
973
1441
  streamingText = ""
974
1442
  return
@@ -979,14 +1447,15 @@
979
1447
  const reply = partsToText(parts)
980
1448
  const replyEl =
981
1449
  promoteLatestThinkingToResponse(reply) || addMessage("agent", reply || "(no response)")
1450
+ if (tokenUsage) attachMsgMeta(replyEl, tokenUsage, durationMs)
982
1451
  finalizeThinking(replyEl, reply)
1452
+ finalizeTurnPanel()
983
1453
  }
984
1454
 
985
- async function send() {
1455
+ function enqueueMessage() {
986
1456
  const text = prompt.value.trim()
987
- if (!text || busy) return
1457
+ if (!text) return
988
1458
 
989
- // Save to input history (skip if same as last entry)
990
1459
  if (inputHistory[inputHistory.length - 1] !== text) {
991
1460
  inputHistory.push(text)
992
1461
  if (inputHistory.length > 100) inputHistory.shift()
@@ -994,24 +1463,45 @@
994
1463
  }
995
1464
  historyIndex = -1
996
1465
  currentDraft = ""
997
-
998
- addMessage("user", text)
999
1466
  prompt.value = ""
1000
1467
  prompt.style.height = "auto"
1468
+ renderComposerAction()
1469
+ const el = addMessage("user", text, statusText)
1470
+ el.classList.add("queued")
1471
+ messageQueue.push({ text, el })
1472
+ processQueue()
1473
+ }
1474
+
1475
+ async function processQueue() {
1476
+ if (processingQueue || pendingTaskId) return
1477
+ processingQueue = true
1478
+ try {
1479
+ while (messageQueue.length && !pendingTaskId) {
1480
+ const message = messageQueue.shift()
1481
+ await sendMessage(message)
1482
+ }
1483
+ } finally {
1484
+ processingQueue = false
1485
+ }
1486
+ }
1487
+
1488
+ async function sendMessage({ text, el }) {
1489
+ el.classList.remove("queued")
1490
+ messages.insertBefore(el, typing.parentElement === messages ? typing : statusText)
1001
1491
  setBusy(true)
1492
+ responseStartTime = performance.now()
1002
1493
 
1003
1494
  const abort = new AbortController()
1004
1495
  activeAbort = abort
1005
1496
  streamingBubble = null
1497
+ lastStreamingBubble = null
1006
1498
  streamingText = ""
1007
1499
  thinkingFinalized = false
1008
1500
  clearPendingThinkingUpdate()
1009
1501
  thinkingStepEls = []
1010
1502
  thinkingTexts = []
1011
- thinkingStepCount = 0
1012
- thinkingSummaryEl = null
1013
- currentThinkingArtifactId = null
1014
- currentThinkingClosed = false
1503
+ turnPanelEl = null
1504
+ toolCallsMap = new Map()
1015
1505
 
1016
1506
  const useStream = useStreaming
1017
1507
  try {
@@ -1029,6 +1519,12 @@
1029
1519
  messageId: crypto.randomUUID(),
1030
1520
  ...(contextId ? { contextId } : {}),
1031
1521
  parts: [{ kind: "text", text }],
1522
+ metadata: {
1523
+ "tool-status-update": {
1524
+ args: true,
1525
+ result: true,
1526
+ },
1527
+ },
1032
1528
  },
1033
1529
  },
1034
1530
  }),
@@ -1133,6 +1629,17 @@
1133
1629
  return null
1134
1630
  }
1135
1631
 
1632
+ if (event.artifact?.artifactId?.startsWith("tool-call-")) {
1633
+ const dataPart = (event.artifact?.parts ?? []).find(
1634
+ (p) => p.kind === "data" && p.data?.type === "tool-call",
1635
+ )
1636
+ if (dataPart) {
1637
+ const id = event.artifact.artifactId.slice("tool-call-".length)
1638
+ updateToolCall(id, dataPart.data)
1639
+ }
1640
+ return null
1641
+ }
1642
+
1136
1643
  if (event.artifact?.artifactId !== "response") return null
1137
1644
 
1138
1645
  // A2A spec: append/lastChunk are event-level fields (siblings of `artifact`).
@@ -1151,7 +1658,9 @@
1151
1658
  }
1152
1659
  }
1153
1660
  finalizeThinking(streamingBubble, text)
1661
+ finalizeTurnPanel()
1154
1662
  thinkingFinalized = true
1663
+ lastStreamingBubble = streamingBubble
1155
1664
  streamingBubble = null
1156
1665
  streamingText = text // dedupe with handleResult
1157
1666
  return null
@@ -1179,11 +1688,13 @@
1179
1688
 
1180
1689
  // Status update with final=true means terminal state
1181
1690
  if (event.kind === "status-update" && event.final) {
1691
+ const tokenUsage = event.metadata?.["sap.cds.agents.token-usage"]
1182
1692
  return {
1183
1693
  id: event.taskId || activeTaskId,
1184
1694
  contextId: event.contextId || contextId,
1185
1695
  status: event.status,
1186
1696
  artifacts: event.artifacts,
1697
+ _tokenUsage: tokenUsage,
1187
1698
  }
1188
1699
  }
1189
1700
 
@@ -1202,11 +1713,8 @@
1202
1713
 
1203
1714
  async function cancel() {
1204
1715
  const taskId = activeTaskId
1716
+ activeAbort?.abort()
1205
1717
 
1206
- // Abort ongoing fetch
1207
- if (activeAbort) activeAbort.abort()
1208
-
1209
- // Send tasks/cancel if we have a taskId
1210
1718
  if (taskId) {
1211
1719
  fetch(AGENT_URL, {
1212
1720
  method: "POST",
@@ -1219,10 +1727,6 @@
1219
1727
  }),
1220
1728
  }).catch(() => {})
1221
1729
  }
1222
-
1223
- addMessage("agent", "⊘ Task canceled.")
1224
- setBusy(false)
1225
- prompt.focus()
1226
1730
  }
1227
1731
  </script>
1228
1732
  </body>