@cap-js/agents 0.9.7 → 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 (37) hide show
  1. package/_i18n/messages.properties +2 -0
  2. package/cds-plugin.js +13 -10
  3. package/lib/agents/middleware/content-filter.js +4 -0
  4. package/lib/agents/middleware/hitl-decision-note-injector.js +18 -6
  5. package/lib/agents/middleware/hitl.js +17 -6
  6. package/lib/agents/middleware/index.js +1 -1
  7. package/lib/agents/middleware/masking.js +6 -3
  8. package/lib/agents/middleware/remote-mcp.js +5 -2
  9. package/lib/agents/middleware/status-update.js +209 -76
  10. package/lib/agents/middleware/tool-wrap.js +1 -2
  11. package/lib/agents/summarize-on-timeout.js +5 -2
  12. package/lib/config/local.js +121 -8
  13. package/lib/eval/eval-run.js +50 -4
  14. package/lib/masking/unstructured/hana.js +13 -5
  15. package/lib/models/aicore.js +16 -3
  16. package/lib/models/openai.js +9 -0
  17. package/lib/preview/chat.html +561 -93
  18. package/lib/telemetry/chat-tracing.js +2 -0
  19. package/lib/telemetry/mlflow/evaluation.js +83 -4
  20. package/lib/telemetry/mlflow/exporter/DatabricksExporter.js +39 -5
  21. package/lib/telemetry/mlflow/exporter/MlflowExporter.js +43 -4
  22. package/lib/telemetry/mlflow/index.js +1 -7
  23. package/lib/telemetry/mlflow/prompts.js +15 -11
  24. package/lib/utils/markdown.js +1 -8
  25. package/lib/utils/resilience.js +2 -1
  26. package/lib/utils/toml.js +67 -0
  27. package/lib/utils/usage.js +34 -0
  28. package/lib/utils/utils.js +43 -1
  29. package/package.json +31 -26
  30. package/srv/handlers/graph-executor/crash-handler.js +47 -0
  31. package/srv/handlers/graph-executor/hitl.js +27 -0
  32. package/srv/handlers/graph-executor.js +19 -5
  33. package/srv/handlers/index.js +6 -1
  34. package/srv/handlers/mcp-tools.js +11 -2
  35. package/srv/handlers/subagent-tools.js +13 -4
  36. package/srv/handlers/system-prompt.js +30 -27
  37. 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;
@@ -410,7 +415,7 @@
410
415
 
411
416
  /* Thinking messages are streamed into an expanded <details> panel,
412
417
  then collapsed once the final response arrives. */
413
- .thinking-summary {
418
+ .turn-panel {
414
419
  align-self: flex-start;
415
420
  margin: 4px 0 8px 0;
416
421
  font-size: 0.88em;
@@ -419,17 +424,17 @@
419
424
  padding-left: 10px;
420
425
  overflow-wrap: anywhere;
421
426
  }
422
- .thinking-summary summary {
427
+ .turn-panel summary {
423
428
  cursor: pointer;
424
429
  user-select: none;
425
430
  color: #888;
426
431
  list-style: none;
427
432
  }
428
- .thinking-summary summary::before {
433
+ .turn-panel > summary::before {
429
434
  content: "▶ ";
430
435
  font-size: 0.8em;
431
436
  }
432
- .thinking-summary[open] summary::before {
437
+ .turn-panel[open] > summary::before {
433
438
  content: "▼ ";
434
439
  }
435
440
  .thinking-step {
@@ -438,27 +443,231 @@
438
443
  font-style: italic;
439
444
  overflow-x: auto;
440
445
  }
441
- .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 {
442
453
  border-top: 1px dashed #e0e0e0;
443
454
  margin-top: 4px;
444
455
  }
445
456
  @media (prefers-reduced-motion: no-preference) {
446
- .thinking-summary {
457
+ .turn-panel {
447
458
  transition: height 0.16s ease;
448
459
  }
449
460
  }
450
461
  @media (prefers-color-scheme: dark) {
451
- .thinking-summary {
462
+ .turn-panel {
452
463
  color: #999;
453
464
  border-left-color: #444;
454
465
  }
455
- .thinking-summary summary {
466
+ .turn-panel summary {
456
467
  color: #aaa;
457
468
  }
458
- .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 {
459
473
  border-top-color: #333;
460
474
  }
461
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
+ }
462
671
  </style>
463
672
  <script src="marked.min.js"></script>
464
673
  </head>
@@ -474,6 +683,12 @@
474
683
  <div class="status-text" id="status-text"></div>
475
684
  </div>
476
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
+
477
692
  <div id="input-row">
478
693
  <textarea id="prompt" rows="1" placeholder="Message {{agentName}}…" autofocus></textarea>
479
694
  <button id="send">Send</button>
@@ -525,16 +740,149 @@
525
740
  let activeTaskId = null
526
741
  let activeAbort = null
527
742
  let streamingBubble = null
743
+ let lastStreamingBubble = null
528
744
  let streamingText = ""
529
745
  let thinkingFinalized = false
530
- let thinkingStepEls = [] // thinking steps inside the summary panel
531
- let thinkingTexts = [] // accumulated text per thinking step
532
- let thinkingStepCount = 0
533
- let thinkingSummaryEl = null // <details> containing thinking steps
534
- let currentThinkingArtifactId = null // artifactId of open thinking step
535
- 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
536
749
  let pendingThinkingUpdate = null
537
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
+ }
538
886
 
539
887
  // Auto-enable streaming when agent card advertises streaming capability
540
888
  fetch(AGENT_URL + ".well-known/agent-card.json", { headers: authHeaders() })
@@ -604,39 +952,43 @@
604
952
  el.innerHTML = md(text, { sanitize: true })
605
953
  else el.textContent = text
606
954
  if (role === "agent_thinking") el.className = "msg agent_thinking"
607
- messages.insertBefore(
608
- el,
609
- beforeEl ?? (typing.parentElement === messages ? typing : statusText),
610
- )
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
+ }
611
965
  messages.scrollTop = messages.scrollHeight
612
966
  return el
613
967
  }
614
968
 
615
- function updateThinkingSummaryLabel() {
616
- if (!thinkingSummaryEl) return
617
- const summary = thinkingSummaryEl.querySelector("summary")
618
- if (!summary) return
619
- const count = thinkingStepCount
620
- 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})`
621
973
  }
622
974
 
623
- function ensureThinkingSummary() {
624
- if (!thinkingSummaryEl) {
625
- thinkingSummaryEl = document.createElement("details")
626
- thinkingSummaryEl.className = "thinking-summary"
627
- thinkingSummaryEl.open = true
975
+ function ensureTurnPanel() {
976
+ if (!turnPanelEl) {
977
+ turnPanelEl = document.createElement("details")
978
+ turnPanelEl.className = "turn-panel"
979
+ turnPanelEl.open = true
628
980
  const summary = document.createElement("summary")
629
- summary.addEventListener("click", toggleThinkingSummary)
630
- thinkingSummaryEl.appendChild(summary)
631
- updateThinkingSummaryLabel()
981
+ summary.addEventListener("click", toggleTurnPanel)
982
+ turnPanelEl.appendChild(summary)
632
983
  messages.insertBefore(
633
- thinkingSummaryEl,
984
+ turnPanelEl,
634
985
  typing.parentElement === messages ? typing : statusText,
635
986
  )
636
987
  }
637
- cancelThinkingSummaryAnimation(thinkingSummaryEl)
638
- thinkingSummaryEl.open = true
639
- return thinkingSummaryEl
988
+ cancelTurnPanelAnimation(turnPanelEl)
989
+ turnPanelEl.open = true
990
+ updateTurnPanelLabel()
991
+ return turnPanelEl
640
992
  }
641
993
 
642
994
  function clearPendingThinkingUpdate() {
@@ -655,34 +1007,31 @@
655
1007
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
656
1008
  }
657
1009
 
658
- function resetThinkingSummaryAnimation(el) {
1010
+ function resetTurnPanelAnimation(el) {
659
1011
  el.style.height = ""
660
1012
  el.style.overflow = ""
661
1013
  }
662
1014
 
663
- function cancelThinkingSummaryAnimation(el) {
1015
+ function cancelTurnPanelAnimation(el) {
664
1016
  if (!el) return
665
- el._thinkingAnimationId = (el._thinkingAnimationId ?? 0) + 1
666
- resetThinkingSummaryAnimation(el)
1017
+ el._animationId = (el._animationId ?? 0) + 1
1018
+ resetTurnPanelAnimation(el)
667
1019
  }
668
1020
 
669
- function toggleThinkingSummary(event) {
1021
+ function toggleTurnPanel(event) {
670
1022
  if (prefersReducedMotion()) return
671
1023
  event.preventDefault()
672
1024
  const el = event.currentTarget.parentElement
673
- if (el.open) collapseThinkingSummary(el)
674
- else expandThinkingSummary(el)
1025
+ if (el.open) collapseTurnPanel(el)
1026
+ else expandTurnPanel(el)
675
1027
  }
676
1028
 
677
1029
  function renderThinkingStep(update) {
678
- const { artifactId, text, lastChunk, append } = update
679
- const panel = ensureThinkingSummary()
680
- const idx = thinkingStepEls.findIndex((step) => step._artifactId === artifactId)
681
- if (append && idx >= 0) {
682
- thinkingTexts[idx] += text
683
- thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
684
- } else if (artifactId === currentThinkingArtifactId && !currentThinkingClosed) {
685
- 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
686
1035
  thinkingStepEls[idx].innerHTML = md(thinkingTexts[idx])
687
1036
  } else {
688
1037
  const step = document.createElement("div")
@@ -692,12 +1041,8 @@
692
1041
  panel.appendChild(step)
693
1042
  thinkingStepEls.push(step)
694
1043
  thinkingTexts.push(text)
695
- thinkingStepCount = thinkingStepEls.length
696
- updateThinkingSummaryLabel()
697
- currentThinkingArtifactId = artifactId
698
- currentThinkingClosed = false
1044
+ updateTurnPanelLabel()
699
1045
  }
700
- if (lastChunk) currentThinkingClosed = true
701
1046
  messages.scrollTop = messages.scrollHeight
702
1047
  }
703
1048
 
@@ -726,24 +1071,112 @@
726
1071
  )
727
1072
  }
728
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
+
729
1159
  function promoteLatestThinkingToResponse(responseText) {
730
- if (!thinkingSummaryEl || !latestThinkingMatches(responseText)) return null
1160
+ if (!turnPanelEl || !latestThinkingMatches(responseText)) return null
731
1161
 
732
1162
  thinkingTexts.pop()
733
1163
  const responseEl = thinkingStepEls.pop()
734
- thinkingStepCount = thinkingStepEls.length
735
1164
 
736
1165
  responseEl.className = "msg agent"
737
1166
  responseEl._promotedFromThinking = true
738
1167
  responseEl.innerHTML = md(responseText)
739
- messages.insertBefore(responseEl, typing.parentElement === messages ? typing : statusText)
740
-
741
- if (thinkingStepEls.length === 0) {
742
- thinkingSummaryEl.remove()
743
- 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
744
1177
  } else {
745
- updateThinkingSummaryLabel()
746
- collapseThinkingSummary(thinkingSummaryEl)
1178
+ updateTurnPanelLabel()
1179
+ collapseTurnPanel(turnPanelEl)
747
1180
  }
748
1181
 
749
1182
  messages.scrollTop = messages.scrollHeight
@@ -753,28 +1186,27 @@
753
1186
  function finalizeThinking(beforeEl, responseText) {
754
1187
  clearPendingThinkingUpdate()
755
1188
  if (beforeEl?._promotedFromThinking) return
756
- if (thinkingStepEls.length === 0 || !thinkingSummaryEl) return
1189
+ if (thinkingStepEls.length === 0 || !turnPanelEl) return
757
1190
  // Stream labels every model turn as thinking until terminal response is
758
1191
  // known. Its final turn is authoritative response, never a thinking step.
759
1192
  thinkingTexts.pop()
760
1193
  const finalResponse = thinkingStepEls.pop()
761
1194
  finalResponse?.remove()
762
- thinkingStepCount = thinkingStepEls.length
763
- updateThinkingSummaryLabel()
1195
+ updateTurnPanelLabel()
764
1196
 
765
- if (thinkingStepEls.length === 0) {
766
- thinkingSummaryEl.remove()
767
- thinkingSummaryEl = null
1197
+ if (thinkingStepEls.length === 0 && toolCallsMap.size === 0) {
1198
+ turnPanelEl.remove()
1199
+ turnPanelEl = null
768
1200
  return
769
1201
  }
770
1202
 
771
- if (beforeEl && thinkingSummaryEl.nextSibling !== beforeEl) {
772
- messages.insertBefore(thinkingSummaryEl, beforeEl)
1203
+ if (beforeEl && turnPanelEl.nextSibling !== (beforeEl._wrap ?? beforeEl)) {
1204
+ messages.insertBefore(turnPanelEl, beforeEl._wrap ?? beforeEl)
773
1205
  }
774
- collapseThinkingSummary(thinkingSummaryEl)
1206
+ collapseTurnPanel(turnPanelEl)
775
1207
  }
776
1208
 
777
- function collapseThinkingSummary(el) {
1209
+ function collapseTurnPanel(el) {
778
1210
  if (!el || !el.open) return
779
1211
 
780
1212
  if (prefersReducedMotion()) {
@@ -785,20 +1217,20 @@
785
1217
  const summary = el.querySelector("summary")
786
1218
  const startHeight = `${el.offsetHeight}px`
787
1219
  const endHeight = `${summary?.offsetHeight ?? 0}px`
788
- const animationId = (el._thinkingAnimationId ?? 0) + 1
789
- el._thinkingAnimationId = animationId
1220
+ const animationId = (el._animationId ?? 0) + 1
1221
+ el._animationId = animationId
790
1222
  let finished = false
791
1223
 
792
1224
  const finish = () => {
793
1225
  el.removeEventListener("transitionend", finish)
794
- if (el._thinkingAnimationId !== animationId) return
1226
+ if (el._animationId !== animationId) return
795
1227
  if (finished) return
796
1228
  finished = true
797
1229
  el.open = false
798
- resetThinkingSummaryAnimation(el)
1230
+ resetTurnPanelAnimation(el)
799
1231
  }
800
1232
 
801
- resetThinkingSummaryAnimation(el)
1233
+ resetTurnPanelAnimation(el)
802
1234
  el.style.overflow = "hidden"
803
1235
  el.style.height = startHeight
804
1236
  requestAnimationFrame(() => {
@@ -808,7 +1240,7 @@
808
1240
  setTimeout(finish, THINKING_SUMMARY_ANIMATION_MS + 80)
809
1241
  }
810
1242
 
811
- function expandThinkingSummary(el) {
1243
+ function expandTurnPanel(el) {
812
1244
  if (!el || el.open) return
813
1245
 
814
1246
  if (prefersReducedMotion()) {
@@ -820,19 +1252,19 @@
820
1252
  const summary = el.querySelector("summary")
821
1253
  const startHeight = `${summary?.offsetHeight ?? 0}px`
822
1254
  const endHeight = `${el.scrollHeight}px`
823
- const animationId = (el._thinkingAnimationId ?? 0) + 1
824
- el._thinkingAnimationId = animationId
1255
+ const animationId = (el._animationId ?? 0) + 1
1256
+ el._animationId = animationId
825
1257
  let finished = false
826
1258
 
827
1259
  const finish = () => {
828
1260
  el.removeEventListener("transitionend", finish)
829
- if (el._thinkingAnimationId !== animationId) return
1261
+ if (el._animationId !== animationId) return
830
1262
  if (finished) return
831
1263
  finished = true
832
- resetThinkingSummaryAnimation(el)
1264
+ resetTurnPanelAnimation(el)
833
1265
  }
834
1266
 
835
- resetThinkingSummaryAnimation(el)
1267
+ resetTurnPanelAnimation(el)
836
1268
  el.style.overflow = "hidden"
837
1269
  el.style.height = startHeight
838
1270
  requestAnimationFrame(() => {
@@ -916,6 +1348,7 @@
916
1348
 
917
1349
  activeTaskId = taskId
918
1350
  setBusy(true)
1351
+ responseStartTime = performance.now()
919
1352
 
920
1353
  const abort = new AbortController()
921
1354
  activeAbort = abort
@@ -936,6 +1369,7 @@
936
1369
  taskId,
937
1370
  contextId,
938
1371
  parts: [{ kind: "text", text: decision }],
1372
+ metadata: { "tool-status-update": { args: true, result: true } },
939
1373
  },
940
1374
  },
941
1375
  }),
@@ -980,9 +1414,19 @@
980
1414
  return
981
1415
  }
982
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
+
983
1423
  // If streaming already rendered the final text via lastChunk, skip.
984
1424
  if (useStreaming && streamingText !== "") {
985
- 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
986
1430
  streamingBubble = null
987
1431
  streamingText = ""
988
1432
  return
@@ -992,6 +1436,7 @@
992
1436
  if (streamingBubble) {
993
1437
  if (streamingText) streamingBubble.innerHTML = md(streamingText)
994
1438
  finalizeThinking(streamingBubble, streamingText)
1439
+ finalizeTurnPanel()
995
1440
  streamingBubble = null
996
1441
  streamingText = ""
997
1442
  return
@@ -1002,7 +1447,9 @@
1002
1447
  const reply = partsToText(parts)
1003
1448
  const replyEl =
1004
1449
  promoteLatestThinkingToResponse(reply) || addMessage("agent", reply || "(no response)")
1450
+ if (tokenUsage) attachMsgMeta(replyEl, tokenUsage, durationMs)
1005
1451
  finalizeThinking(replyEl, reply)
1452
+ finalizeTurnPanel()
1006
1453
  }
1007
1454
 
1008
1455
  function enqueueMessage() {
@@ -1042,19 +1489,19 @@
1042
1489
  el.classList.remove("queued")
1043
1490
  messages.insertBefore(el, typing.parentElement === messages ? typing : statusText)
1044
1491
  setBusy(true)
1492
+ responseStartTime = performance.now()
1045
1493
 
1046
1494
  const abort = new AbortController()
1047
1495
  activeAbort = abort
1048
1496
  streamingBubble = null
1497
+ lastStreamingBubble = null
1049
1498
  streamingText = ""
1050
1499
  thinkingFinalized = false
1051
1500
  clearPendingThinkingUpdate()
1052
1501
  thinkingStepEls = []
1053
1502
  thinkingTexts = []
1054
- thinkingStepCount = 0
1055
- thinkingSummaryEl = null
1056
- currentThinkingArtifactId = null
1057
- currentThinkingClosed = false
1503
+ turnPanelEl = null
1504
+ toolCallsMap = new Map()
1058
1505
 
1059
1506
  const useStream = useStreaming
1060
1507
  try {
@@ -1072,6 +1519,12 @@
1072
1519
  messageId: crypto.randomUUID(),
1073
1520
  ...(contextId ? { contextId } : {}),
1074
1521
  parts: [{ kind: "text", text }],
1522
+ metadata: {
1523
+ "tool-status-update": {
1524
+ args: true,
1525
+ result: true,
1526
+ },
1527
+ },
1075
1528
  },
1076
1529
  },
1077
1530
  }),
@@ -1176,6 +1629,17 @@
1176
1629
  return null
1177
1630
  }
1178
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
+
1179
1643
  if (event.artifact?.artifactId !== "response") return null
1180
1644
 
1181
1645
  // A2A spec: append/lastChunk are event-level fields (siblings of `artifact`).
@@ -1194,7 +1658,9 @@
1194
1658
  }
1195
1659
  }
1196
1660
  finalizeThinking(streamingBubble, text)
1661
+ finalizeTurnPanel()
1197
1662
  thinkingFinalized = true
1663
+ lastStreamingBubble = streamingBubble
1198
1664
  streamingBubble = null
1199
1665
  streamingText = text // dedupe with handleResult
1200
1666
  return null
@@ -1222,11 +1688,13 @@
1222
1688
 
1223
1689
  // Status update with final=true means terminal state
1224
1690
  if (event.kind === "status-update" && event.final) {
1691
+ const tokenUsage = event.metadata?.["sap.cds.agents.token-usage"]
1225
1692
  return {
1226
1693
  id: event.taskId || activeTaskId,
1227
1694
  contextId: event.contextId || contextId,
1228
1695
  status: event.status,
1229
1696
  artifacts: event.artifacts,
1697
+ _tokenUsage: tokenUsage,
1230
1698
  }
1231
1699
  }
1232
1700