@cap-js/agents 0.0.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/_i18n/messages.properties +31 -0
  4. package/cds-plugin.js +140 -0
  5. package/index.cds +116 -0
  6. package/index.js +0 -0
  7. package/lib/agents/markdown/backends/mime-utils.js +37 -0
  8. package/lib/agents/markdown/backends/outputs-backend.js +156 -0
  9. package/lib/agents/markdown/backends/readonly-backend.js +48 -0
  10. package/lib/agents/markdown/backends/uploads-backend.js +160 -0
  11. package/lib/agents/markdown/deep-agent.js +82 -0
  12. package/lib/agents/middleware/agent-actions.js +18 -0
  13. package/lib/agents/middleware/content-filter.js +191 -0
  14. package/lib/agents/middleware/hitl-edit-note-injector.js +18 -0
  15. package/lib/agents/middleware/hitl.js +20 -0
  16. package/lib/agents/middleware/index.js +19 -0
  17. package/lib/agents/middleware/patch-tool-calls.js +51 -0
  18. package/lib/agents/middleware/quota-enforcer.js +94 -0
  19. package/lib/agents/middleware/status-update.js +153 -0
  20. package/lib/agents/middleware/tool-selection.js +22 -0
  21. package/lib/agents/quota-enforcer-at-start.js +198 -0
  22. package/lib/agents/summarize-on-timeout.js +91 -0
  23. package/lib/compile.js +55 -0
  24. package/lib/index.cjs +1 -0
  25. package/lib/index.js +422 -0
  26. package/lib/models/aicore.js +441 -0
  27. package/lib/models/anthropic.js +77 -0
  28. package/lib/models/mock.js +88 -0
  29. package/lib/preview/chat.html +897 -0
  30. package/lib/preview/preview.js +46 -0
  31. package/lib/protocol/agent-card.js +297 -0
  32. package/lib/protocol/persistence/checkpoint-saver.js +320 -0
  33. package/lib/protocol/persistence/cleanup.js +84 -0
  34. package/lib/protocol/persistence/file-store.js +209 -0
  35. package/lib/protocol/persistence/push-notification-store.js +59 -0
  36. package/lib/protocol/persistence/task-store.js +47 -0
  37. package/lib/protocol/push-notification-sender.js +57 -0
  38. package/lib/sidecar.js +162 -0
  39. package/lib/telemetry/active-users.js +106 -0
  40. package/lib/telemetry/chat-tracing.js +364 -0
  41. package/lib/telemetry/metrics.js +85 -0
  42. package/lib/telemetry/mlflow.js +290 -0
  43. package/lib/telemetry/tool-tracing.js +164 -0
  44. package/lib/telemetry/tracing.js +150 -0
  45. package/lib/utils/inner-auth.js +33 -0
  46. package/lib/utils/markdown.js +199 -0
  47. package/lib/utils/message-handling.js +155 -0
  48. package/lib/utils/utils.js +168 -0
  49. package/package.json +225 -2
  50. package/srv/graph-cache.js +82 -0
  51. package/srv/handlers/graph-executor.js +1374 -0
  52. package/srv/handlers/index.js +178 -0
  53. package/srv/handlers/mcp-tools.js +161 -0
  54. package/srv/handlers/sub-agent-tools.js +316 -0
  55. package/srv/handlers/system-prompt.js +25 -0
  56. package/srv/handlers/tools.js +366 -0
  57. package/srv/langgraph-executor-srv.js +70 -0
  58. package/srv/push-notification-srv.js +149 -0
@@ -0,0 +1,897 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>{{agentName}}</title>
5
+ <meta charset="utf-8" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <meta name="a2a-auth" content="{{authHeader}}" />
8
+ <style>
9
+ body {
10
+ font-family:
11
+ Avenir Next,
12
+ sans-serif;
13
+ margin: 0;
14
+ background: #f8fbff;
15
+ color: #2a3d52;
16
+ display: flex;
17
+ flex-direction: column;
18
+ height: 100dvh;
19
+ }
20
+
21
+ header {
22
+ background: #537492;
23
+ color: #fff;
24
+ padding: 12px 20px;
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 12px;
28
+ flex-shrink: 0;
29
+ }
30
+ header h1 {
31
+ font-size: 1rem;
32
+ font-weight: 500;
33
+ margin: 0;
34
+ }
35
+ header .back {
36
+ color: #c8def0;
37
+ text-decoration: none;
38
+ font-size: 0.85rem;
39
+ cursor: pointer;
40
+ }
41
+ header .back:hover {
42
+ color: #fff;
43
+ }
44
+ .header-toggle {
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 6px;
48
+ font-size: 0.78rem;
49
+ color: #c8def0;
50
+ cursor: pointer;
51
+ user-select: none;
52
+ flex-shrink: 0;
53
+ }
54
+ .header-toggle[hidden] {
55
+ display: none;
56
+ }
57
+ .header-toggle input[type="checkbox"] {
58
+ accent-color: #7fbce8;
59
+ width: 14px;
60
+ height: 14px;
61
+ cursor: pointer;
62
+ }
63
+
64
+ header .status-dot {
65
+ width: 8px;
66
+ height: 8px;
67
+ border-radius: 50%;
68
+ background: #4caf50;
69
+ flex-shrink: 0;
70
+ transition: background 0.3s;
71
+ }
72
+ header .status-dot.busy {
73
+ background: #f9a825;
74
+ }
75
+ header .status-dot.error {
76
+ background: #e53935;
77
+ }
78
+
79
+ table thead {
80
+ text-align: left;
81
+ }
82
+ table {
83
+ border-spacing: 1em 0.2em;
84
+ margin: 1em 0;
85
+ }
86
+
87
+ #messages {
88
+ flex: 1;
89
+ overflow-y: auto;
90
+ padding: 20px;
91
+ display: flex;
92
+ flex-direction: column;
93
+ gap: 14px;
94
+ }
95
+
96
+ .msg {
97
+ max-width: 75%;
98
+ padding: 10px 14px;
99
+ border-radius: 12px;
100
+ line-height: 1.5;
101
+ font-size: 90%;
102
+ word-break: break-word;
103
+ }
104
+ .msg.user {
105
+ align-self: flex-end;
106
+ background: #537492;
107
+ color: #fff;
108
+ border-bottom-right-radius: 3px;
109
+ white-space: pre-wrap;
110
+ }
111
+ .msg.agent {
112
+ align-self: flex-start;
113
+ background: #d0e9ff;
114
+ color: #1d3e5a;
115
+ border-bottom-left-radius: 3px;
116
+ }
117
+ .msg.error {
118
+ align-self: flex-start;
119
+ background: #fde8e8;
120
+ color: #b71c1c;
121
+ border-bottom-left-radius: 3px;
122
+ white-space: pre-wrap;
123
+ }
124
+ .msg.approval {
125
+ align-self: flex-start;
126
+ background: #d0e9ff;
127
+ color: #1d3e5a;
128
+ border-bottom-left-radius: 3px;
129
+ border-left: 3px solid #537492;
130
+ }
131
+ .msg.approval::before {
132
+ content: "Action Required";
133
+ display: block;
134
+ font-size: 0.75rem;
135
+ font-weight: 600;
136
+ color: #537492;
137
+ margin-bottom: 6px;
138
+ letter-spacing: 0.03em;
139
+ text-transform: uppercase;
140
+ }
141
+
142
+ .msg.agent p {
143
+ margin: 0 0 6px;
144
+ }
145
+ .msg.agent p:last-child {
146
+ margin-bottom: 0;
147
+ }
148
+ .msg.agent h1,
149
+ .msg.agent h2,
150
+ .msg.agent h3 {
151
+ font-weight: 600;
152
+ margin: 8px 0 4px;
153
+ }
154
+ .msg.agent h1 {
155
+ font-size: 1.1em;
156
+ }
157
+ .msg.agent h2 {
158
+ font-size: 1em;
159
+ }
160
+ .msg.agent h3 {
161
+ font-size: 0.95em;
162
+ }
163
+ .msg.agent ul,
164
+ .msg.agent ol {
165
+ margin: 4px 0 6px 18px;
166
+ padding: 0;
167
+ }
168
+ .msg.agent li {
169
+ margin-bottom: 2px;
170
+ }
171
+ .msg.agent pre {
172
+ background: rgba(0, 0, 0, 0.08);
173
+ border-radius: 6px;
174
+ padding: 8px 10px;
175
+ overflow-x: auto;
176
+ margin: 6px 0;
177
+ white-space: pre;
178
+ }
179
+ .msg.agent code {
180
+ font-family: ui-monospace, "Cascadia Code", monospace;
181
+ font-size: 0.85em;
182
+ background: rgba(0, 0, 0, 0.08);
183
+ padding: 1px 4px;
184
+ border-radius: 3px;
185
+ }
186
+ .msg.agent pre code {
187
+ background: none;
188
+ padding: 0;
189
+ font-size: 0.82em;
190
+ }
191
+ .msg.agent a {
192
+ color: inherit;
193
+ text-decoration: underline;
194
+ }
195
+ .msg.agent hr {
196
+ border: none;
197
+ border-top: 1px solid rgba(0, 0, 0, 0.15);
198
+ margin: 8px 0;
199
+ }
200
+
201
+ .typing {
202
+ align-self: flex-start;
203
+ padding: 10px 16px;
204
+ background: #d0e9ff;
205
+ border-radius: 12px;
206
+ border-bottom-left-radius: 3px;
207
+ display: none;
208
+ }
209
+ .typing span {
210
+ display: inline-block;
211
+ width: 7px;
212
+ height: 7px;
213
+ background: #537492;
214
+ border-radius: 50%;
215
+ animation: bounce 1.2s infinite;
216
+ }
217
+ .typing span:nth-child(2) {
218
+ animation-delay: 0.2s;
219
+ }
220
+ .typing span:nth-child(3) {
221
+ animation-delay: 0.4s;
222
+ }
223
+ @keyframes bounce {
224
+ 0%,
225
+ 80%,
226
+ 100% {
227
+ transform: translateY(0);
228
+ }
229
+ 40% {
230
+ transform: translateY(-6px);
231
+ }
232
+ }
233
+
234
+ .status-text {
235
+ font-size: 0.78rem;
236
+ color: #6a8fa8;
237
+ padding: 2px 12px;
238
+ font-style: italic;
239
+ }
240
+
241
+ #input-row {
242
+ display: flex;
243
+ gap: 10px;
244
+ padding: 14px 20px;
245
+ border-top: 1px solid #c8d9e8;
246
+ background: #f8fbff;
247
+ flex-shrink: 0;
248
+ }
249
+
250
+ #prompt {
251
+ flex: 1;
252
+ padding: 10px 14px;
253
+ border: 1px solid #91adc5;
254
+ border-radius: 8px;
255
+ font-family: inherit;
256
+ font-size: 0.9rem;
257
+ resize: none;
258
+ outline: none;
259
+ background: #fff;
260
+ color: inherit;
261
+ line-height: 1.4;
262
+ max-height: 140px;
263
+ overflow-y: auto;
264
+ }
265
+ #prompt:focus {
266
+ border-color: #537492;
267
+ }
268
+
269
+ #send {
270
+ align-self: flex-end;
271
+ padding: 10px 20px;
272
+ background: #537492;
273
+ color: #fff;
274
+ border: none;
275
+ border-radius: 8px;
276
+ cursor: pointer;
277
+ font-family: inherit;
278
+ font-size: 0.9rem;
279
+ transition: background 0.15s;
280
+ }
281
+ #send:hover:not(:disabled) {
282
+ background: #3e5a73;
283
+ }
284
+ #send:disabled {
285
+ background: #91adc5;
286
+ cursor: not-allowed;
287
+ }
288
+ #send.cancel {
289
+ background: #e53935;
290
+ }
291
+ #send.cancel:hover {
292
+ background: #c62828;
293
+ }
294
+
295
+ .approval-actions {
296
+ display: flex;
297
+ gap: 8px;
298
+ margin-top: 10px;
299
+ }
300
+ .approval-actions button {
301
+ padding: 7px 18px;
302
+ border: none;
303
+ border-radius: 7px;
304
+ cursor: pointer;
305
+ font-family: inherit;
306
+ font-size: 0.85rem;
307
+ font-weight: 500;
308
+ transition: background 0.15s;
309
+ }
310
+ .btn-approve {
311
+ background: #537492;
312
+ color: #fff;
313
+ }
314
+ .btn-approve:hover {
315
+ background: #3e5a73;
316
+ }
317
+ .btn-reject {
318
+ background: transparent;
319
+ color: #537492;
320
+ border: 1px solid #91adc5;
321
+ }
322
+ .btn-reject:hover {
323
+ background: #c8d9e8;
324
+ }
325
+
326
+ @media (prefers-color-scheme: dark) {
327
+ body {
328
+ background: #0a2138;
329
+ color: #c8dff0;
330
+ }
331
+ header {
332
+ background: #304c65;
333
+ }
334
+ #input-row {
335
+ background: #0a2138;
336
+ border-color: #2a4a63;
337
+ }
338
+ #prompt {
339
+ background: #0f2d45;
340
+ border-color: #2a4a63;
341
+ color: #c8dff0;
342
+ }
343
+ #prompt:focus {
344
+ border-color: #537492;
345
+ }
346
+ .msg.agent {
347
+ background: #1a3d58;
348
+ color: #c8dff0;
349
+ }
350
+ .msg.user {
351
+ background: #537492;
352
+ }
353
+ .msg.error {
354
+ background: #3b1818;
355
+ color: #ff8a80;
356
+ }
357
+ .typing {
358
+ background: #1a3d58;
359
+ }
360
+ .typing span {
361
+ background: #91adc5;
362
+ }
363
+ #send {
364
+ background: #537492;
365
+ }
366
+ #send:hover:not(:disabled) {
367
+ background: #6a8fa8;
368
+ }
369
+ #send:disabled {
370
+ background: #304c65;
371
+ }
372
+ #send.cancel {
373
+ background: #e53935;
374
+ }
375
+ #send.cancel:hover {
376
+ background: #ef5350;
377
+ }
378
+ .msg.approval {
379
+ background: #1a3d58;
380
+ color: #c8dff0;
381
+ border-left-color: #91adc5;
382
+ }
383
+ .msg.approval::before {
384
+ color: #91adc5;
385
+ }
386
+ .btn-approve {
387
+ background: #537492;
388
+ }
389
+ .btn-approve:hover {
390
+ background: #6a8fa8;
391
+ }
392
+ .btn-reject {
393
+ color: #c8dff0;
394
+ border-color: #2a4a63;
395
+ }
396
+ .btn-reject:hover {
397
+ background: #0f2d45;
398
+ }
399
+ }
400
+ </style>
401
+ <script src="marked.min.js"></script>
402
+ </head>
403
+ <body>
404
+ <header>
405
+ <a class="back" id="back" title="Back to index">&#8592;</a>
406
+ <h1>{{agentName}}</h1>
407
+ <label
408
+ class="header-toggle"
409
+ title="Stream tokens as they arrive"
410
+ style="margin-left: auto"
411
+ hidden
412
+ >
413
+ <input type="checkbox" id="streamToggle" hidden />
414
+ Streaming
415
+ </label>
416
+ <div class="status-dot" id="dot"></div>
417
+ </header>
418
+
419
+ <div id="messages">
420
+ <div class="typing" id="typing"><span></span><span></span><span></span></div>
421
+ <div class="status-text" id="status-text"></div>
422
+ </div>
423
+
424
+ <div id="input-row">
425
+ <textarea id="prompt" rows="1" placeholder="Message {{agentName}}…" autofocus></textarea>
426
+ <button id="send">Send</button>
427
+ </div>
428
+
429
+ <script>
430
+ const AGENT_URL = window.location.pathname.replace(/\/preview\/?$/, "") + "/"
431
+ const AUTH_HEADER = document.querySelector('meta[name="a2a-auth"]')?.content || ""
432
+
433
+ const fetchCsrfToken = async () => {
434
+ if (document.csrfToken === undefined) {
435
+ const res = await fetch("/", {
436
+ method: "HEAD",
437
+ headers: { "x-csrf-token": "fetch", ...(await authHeaders({ includeCsrf: false })) },
438
+ })
439
+ if (!res.ok) {
440
+ throw new Error(`Could not fetch CSRF token ${res.status}: ${res.statusText}`)
441
+ }
442
+ document.csrfToken = res.headers.get("x-csrf-token")
443
+ }
444
+ return document.csrfToken
445
+ }
446
+
447
+ const authHeaders = async ({ includeCsrf = true } = {}) => {
448
+ const headers = AUTH_HEADER ? { Authorization: AUTH_HEADER } : {}
449
+ if (!includeCsrf) return headers
450
+ const csrfToken = await fetchCsrfToken()
451
+ if (csrfToken) headers["x-csrf-token"] = csrfToken
452
+ return headers
453
+ }
454
+
455
+ document.getElementById("back").addEventListener("click", () => history.back())
456
+ const messages = document.getElementById("messages")
457
+ const typing = document.getElementById("typing")
458
+ const statusText = document.getElementById("status-text")
459
+ const prompt = document.getElementById("prompt")
460
+ const sendBtn = document.getElementById("send")
461
+ const dot = document.getElementById("dot")
462
+ const streamToggle = document.getElementById("streamToggle")
463
+
464
+ let busy = false
465
+ let contextId = null
466
+ let pendingTaskId = null
467
+ let activeTaskId = null
468
+ let activeAbort = null
469
+ // Live streaming bubble: created on first token, updated incrementally
470
+ let streamingBubble = null
471
+ let streamingText = ""
472
+
473
+ const HISTORY_KEY = "chatHistory_" + AGENT_URL
474
+ let inputHistory = JSON.parse(localStorage.getItem(HISTORY_KEY) || "[]")
475
+ let historyIndex = -1
476
+ let currentDraft = ""
477
+
478
+ const md = (text) => marked.parse(text, { breaks: true, gfm: true })
479
+
480
+ const partsToText = (parts) =>
481
+ (parts ?? [])
482
+ .filter((p) => p.kind === "text" || p.text)
483
+ .map((p) => p.text)
484
+ .join("")
485
+
486
+ prompt.addEventListener("input", () => {
487
+ prompt.style.height = "auto"
488
+ prompt.style.height = Math.min(prompt.scrollHeight, 140) + "px"
489
+ })
490
+ prompt.addEventListener("keydown", (e) => {
491
+ if (e.key === "Enter" && !e.shiftKey) {
492
+ e.preventDefault()
493
+ if (busy) cancel()
494
+ else send()
495
+ } else if (e.key === "ArrowUp" && prompt.selectionStart === 0) {
496
+ e.preventDefault()
497
+ if (historyIndex === -1) currentDraft = prompt.value
498
+ if (historyIndex < inputHistory.length - 1) {
499
+ historyIndex++
500
+ prompt.value = inputHistory[inputHistory.length - 1 - historyIndex]
501
+ prompt.selectionStart = prompt.selectionEnd = prompt.value.length
502
+ }
503
+ } else if (e.key === "ArrowDown" && prompt.selectionEnd === prompt.value.length) {
504
+ e.preventDefault()
505
+ if (historyIndex > 0) {
506
+ historyIndex--
507
+ prompt.value = inputHistory[inputHistory.length - 1 - historyIndex]
508
+ prompt.selectionStart = prompt.selectionEnd = prompt.value.length
509
+ } else if (historyIndex === 0) {
510
+ historyIndex = -1
511
+ prompt.value = currentDraft
512
+ prompt.selectionStart = prompt.selectionEnd = prompt.value.length
513
+ }
514
+ }
515
+ })
516
+ sendBtn.addEventListener("click", () => {
517
+ if (busy) cancel()
518
+ else send()
519
+ })
520
+
521
+ function addMessage(role, text) {
522
+ const el = document.createElement("div")
523
+ el.className = "msg " + role
524
+ if (role === "agent") el.innerHTML = md(text, { sanitize: true })
525
+ else el.textContent = text
526
+ messages.insertBefore(el, typing)
527
+ messages.scrollTop = messages.scrollHeight
528
+ return el
529
+ }
530
+
531
+ function addApprovalPrompt(description) {
532
+ const el = document.createElement("div")
533
+ el.className = "msg approval"
534
+ el.innerHTML = md(description || "The agent requires your approval to continue.")
535
+ const actions = document.createElement("div")
536
+ actions.className = "approval-actions"
537
+
538
+ const approveBtn = document.createElement("button")
539
+ approveBtn.className = "btn-approve"
540
+ approveBtn.textContent = "Approve"
541
+ approveBtn.addEventListener("click", () => {
542
+ removeApprovalPrompt(el)
543
+ resume("approve")
544
+ })
545
+
546
+ const rejectBtn = document.createElement("button")
547
+ rejectBtn.className = "btn-reject"
548
+ rejectBtn.textContent = "Reject"
549
+ rejectBtn.addEventListener("click", () => {
550
+ removeApprovalPrompt(el)
551
+ resume("reject")
552
+ })
553
+
554
+ actions.appendChild(approveBtn)
555
+ actions.appendChild(rejectBtn)
556
+ el.appendChild(actions)
557
+ messages.insertBefore(el, typing)
558
+ messages.scrollTop = messages.scrollHeight
559
+ return el
560
+ }
561
+
562
+ function removeApprovalPrompt(el) {
563
+ el.remove()
564
+ }
565
+
566
+ function setBusy(val) {
567
+ busy = val
568
+ prompt.disabled = val
569
+ typing.style.display = val ? "block" : "none"
570
+ dot.className = "status-dot" + (val ? " busy" : "")
571
+ if (val) {
572
+ sendBtn.textContent = "Cancel"
573
+ sendBtn.classList.add("cancel")
574
+ sendBtn.disabled = false
575
+ } else {
576
+ sendBtn.textContent = "Send"
577
+ sendBtn.classList.remove("cancel")
578
+ sendBtn.disabled = false
579
+ activeTaskId = null
580
+ activeAbort = null
581
+ statusText.textContent = ""
582
+ // Reset streaming state on any transition to idle
583
+ streamingBubble = null
584
+ streamingText = ""
585
+ }
586
+ messages.scrollTop = messages.scrollHeight
587
+ }
588
+
589
+ async function resume(decision) {
590
+ const taskId = pendingTaskId
591
+ pendingTaskId = null
592
+
593
+ activeTaskId = taskId
594
+ setBusy(true)
595
+
596
+ const abort = new AbortController()
597
+ activeAbort = abort
598
+ streamingBubble = null
599
+ streamingText = ""
600
+
601
+ try {
602
+ const resp = await fetch(AGENT_URL, {
603
+ method: "POST",
604
+ headers: { "Content-Type": "application/json", ...(await authHeaders()) },
605
+ signal: abort.signal,
606
+ body: JSON.stringify({
607
+ jsonrpc: "2.0",
608
+ method: streamToggle.checked ? "message/stream" : "message/send",
609
+ id: crypto.randomUUID(),
610
+ params: {
611
+ message: {
612
+ role: "user",
613
+ messageId: crypto.randomUUID(),
614
+ taskId,
615
+ contextId,
616
+ parts: [{ kind: "text", text: decision }],
617
+ },
618
+ },
619
+ }),
620
+ })
621
+
622
+ if (streamToggle.checked) {
623
+ await handleSSEResponse(resp)
624
+ } else {
625
+ const envelope = await resp.json()
626
+ if (envelope.error) throw new Error(envelope.error.message || "Request failed")
627
+ await handleResult(envelope.result)
628
+ }
629
+ } catch (err) {
630
+ if (err.name === "AbortError") return
631
+ dot.className = "status-dot error"
632
+ addMessage("error", "⚠ " + (err.message || String(err)))
633
+ setTimeout(() => (dot.className = "status-dot"), 3000)
634
+ } finally {
635
+ setBusy(false)
636
+ prompt.focus()
637
+ }
638
+ }
639
+
640
+ async function handleResult(result) {
641
+ if (result?.contextId) contextId = result.contextId
642
+ if (result?.id) activeTaskId = result.id
643
+
644
+ const state = result?.status?.state ?? result?.state
645
+ const taskId = result?.id ?? result?.taskId
646
+
647
+ if (state === "input-required") {
648
+ pendingTaskId = taskId
649
+ const msgParts = result?.status?.message?.parts ?? []
650
+ const description = partsToText(msgParts)
651
+ addApprovalPrompt(description)
652
+ return
653
+ }
654
+
655
+ // If streaming was active and already rendered the final text via lastChunk, skip.
656
+ if (streamToggle.checked && streamingText !== "") {
657
+ streamingText = ""
658
+ return
659
+ }
660
+
661
+ // Clean up any open bubble (streaming was interrupted before lastChunk)
662
+ if (streamingBubble) {
663
+ if (streamingText) streamingBubble.innerHTML = md(streamingText)
664
+ streamingBubble = null
665
+ streamingText = ""
666
+ return
667
+ }
668
+
669
+ const parts =
670
+ result?.parts ?? result?.status?.message?.parts ?? result?.artifacts?.[0]?.parts ?? []
671
+ const reply = partsToText(parts)
672
+ addMessage("agent", reply || "(no response)")
673
+ }
674
+
675
+ async function send() {
676
+ const text = prompt.value.trim()
677
+ if (!text || busy) return
678
+
679
+ // Save to input history (skip if same as last entry)
680
+ if (inputHistory[inputHistory.length - 1] !== text) {
681
+ inputHistory.push(text)
682
+ if (inputHistory.length > 100) inputHistory.shift()
683
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(inputHistory))
684
+ }
685
+ historyIndex = -1
686
+ currentDraft = ""
687
+
688
+ addMessage("user", text)
689
+ prompt.value = ""
690
+ prompt.style.height = "auto"
691
+ setBusy(true)
692
+
693
+ const abort = new AbortController()
694
+ activeAbort = abort
695
+ streamingBubble = null
696
+ streamingText = ""
697
+
698
+ const useStream = streamToggle.checked
699
+ try {
700
+ const resp = await fetch(AGENT_URL, {
701
+ method: "POST",
702
+ headers: { "Content-Type": "application/json", ...(await authHeaders()) },
703
+ signal: abort.signal,
704
+ body: JSON.stringify({
705
+ jsonrpc: "2.0",
706
+ method: useStream ? "message/stream" : "message/send",
707
+ id: crypto.randomUUID(),
708
+ params: {
709
+ message: {
710
+ role: "user",
711
+ messageId: crypto.randomUUID(),
712
+ ...(contextId ? { contextId } : {}),
713
+ parts: [{ kind: "text", text }],
714
+ },
715
+ },
716
+ }),
717
+ })
718
+
719
+ if (useStream) {
720
+ await handleSSEResponse(resp)
721
+ } else {
722
+ const envelope = await resp.json()
723
+ if (envelope.error) throw new Error(envelope.error.message || "Request failed")
724
+ await handleResult(envelope.result)
725
+ }
726
+ } catch (err) {
727
+ if (err.name === "AbortError") return
728
+ dot.className = "status-dot error"
729
+ addMessage("error", "⚠ " + (err.message || String(err)))
730
+ setTimeout(() => {
731
+ dot.className = "status-dot"
732
+ }, 3000)
733
+ } finally {
734
+ setBusy(false)
735
+ prompt.focus()
736
+ }
737
+ }
738
+
739
+ async function handleSSEResponse(resp) {
740
+ if (!resp.ok) {
741
+ const body = await resp.text()
742
+ let parsed
743
+ try {
744
+ parsed = JSON.parse(body)
745
+ } catch (e) {
746
+ /* not JSON */
747
+ }
748
+ throw new Error(parsed?.error?.message || `HTTP ${resp.status}: ${body.slice(0, 200)}`)
749
+ }
750
+
751
+ const reader = resp.body.getReader()
752
+ const decoder = new TextDecoder()
753
+ let buffer = ""
754
+ let lastResult = null
755
+
756
+ while (true) {
757
+ const { done, value } = await reader.read()
758
+ if (done) break
759
+ buffer += decoder.decode(value, { stream: true })
760
+
761
+ let lineEnd
762
+ while ((lineEnd = buffer.indexOf("\n")) !== -1) {
763
+ const line = buffer.slice(0, lineEnd).trim()
764
+ buffer = buffer.slice(lineEnd + 1)
765
+
766
+ if (line.startsWith("data: ")) {
767
+ try {
768
+ const frame = JSON.parse(line.slice(6))
769
+ // SSE frames are JSON-RPC envelopes: {jsonrpc, id, result} or {jsonrpc, id, error}
770
+ if (frame.error) throw new Error(frame.error.message || "Stream error")
771
+ if (frame.result) {
772
+ lastResult = processSSEEvent(frame.result) || lastResult
773
+ }
774
+ } catch (e) {
775
+ if (e instanceof SyntaxError) continue
776
+ throw e
777
+ }
778
+ } else if (line.startsWith("event: error")) {
779
+ // Next data line will have the error
780
+ }
781
+ }
782
+ }
783
+
784
+ if (lastResult) await handleResult(lastResult)
785
+ }
786
+
787
+ function processSSEEvent(event) {
788
+ // Extract taskId from first task event
789
+ if (event.kind === "task" && event.id) {
790
+ activeTaskId = event.id
791
+ contextId = event.contextId ?? contextId
792
+ }
793
+
794
+ // Track contextId from status-updates
795
+ if (event.contextId) contextId = event.contextId
796
+
797
+ // Non-final status-update with message → show as progress text from streaming
798
+ if (event.kind === "status-update" && !event.final && event.status?.message) {
799
+ const text = partsToText(event.status.message.parts)
800
+ if (text) {
801
+ statusText.textContent = text
802
+ messages.scrollTop = messages.scrollHeight
803
+ }
804
+ return null
805
+ }
806
+
807
+ // Incremental artifact-update: render tokens as they arrive
808
+ if (event.kind === "artifact-update" && streamToggle.checked) {
809
+ const parts = event.artifact?.parts ?? []
810
+ const text = partsToText(parts)
811
+ // A2A spec: append/lastChunk are event-level fields (siblings of `artifact`).
812
+ const append = event.append ?? false
813
+ const lastChunk = event.lastChunk ?? false
814
+
815
+ if (lastChunk) {
816
+ // lastChunk carries the authoritative full text — replace accumulated content.
817
+ if (text) {
818
+ if (streamingBubble) {
819
+ streamingBubble.innerHTML = md(text)
820
+ messages.scrollTop = messages.scrollHeight
821
+ } else {
822
+ typing.style.display = "none"
823
+ addMessage("agent", text)
824
+ }
825
+ }
826
+ streamingBubble = null
827
+ streamingText = text // keep so handleResult skips duplicate render
828
+ return null
829
+ }
830
+
831
+ if (text) {
832
+ if (!append || !streamingBubble) {
833
+ // First token — create a live bubble, hide typing indicator
834
+ typing.style.display = "none"
835
+ streamingBubble = addMessage("agent", text)
836
+ streamingText = text
837
+ } else {
838
+ // Subsequent token — accumulate and re-render markdown
839
+ streamingText += text
840
+ streamingBubble.innerHTML = md(streamingText)
841
+ messages.scrollTop = messages.scrollHeight
842
+ }
843
+ }
844
+
845
+ return null
846
+ }
847
+
848
+ // Status update with final=true means terminal state
849
+ if (event.kind === "status-update" && event.final) {
850
+ return {
851
+ id: event.taskId || activeTaskId,
852
+ contextId: event.contextId || contextId,
853
+ status: event.status,
854
+ artifacts: event.artifacts,
855
+ }
856
+ }
857
+
858
+ // Artifact event
859
+ if (event.kind === "artifact") {
860
+ return {
861
+ id: event.taskId || activeTaskId,
862
+ contextId: event.contextId || contextId,
863
+ status: event.status || { state: "completed" },
864
+ artifacts: [event.artifact || event],
865
+ }
866
+ }
867
+
868
+ return null
869
+ }
870
+
871
+ async function cancel() {
872
+ const taskId = activeTaskId
873
+
874
+ // Abort ongoing fetch
875
+ if (activeAbort) activeAbort.abort()
876
+
877
+ // Send tasks/cancel if we have a taskId
878
+ if (taskId) {
879
+ fetch(AGENT_URL, {
880
+ method: "POST",
881
+ headers: { "Content-Type": "application/json", ...(await authHeaders()) },
882
+ body: JSON.stringify({
883
+ jsonrpc: "2.0",
884
+ method: "tasks/cancel",
885
+ id: crypto.randomUUID(),
886
+ params: { id: taskId },
887
+ }),
888
+ }).catch(() => {})
889
+ }
890
+
891
+ addMessage("agent", "⊘ Task canceled.")
892
+ setBusy(false)
893
+ prompt.focus()
894
+ }
895
+ </script>
896
+ </body>
897
+ </html>