@hsb3/carbon-agui-adapter 0.1.0

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.
@@ -0,0 +1,557 @@
1
+ import { CARBON_COVERAGE } from './coverage.js';
2
+ import { validateAgUiEvent, validateCarbonItem } from './validate.js';
3
+ /**
4
+ * Known Carbon response_type values — the allowlist for CUSTOM item dispatch.
5
+ * Derived from the coverage matrix so it can never drift from the declared set.
6
+ */
7
+ const KNOWN_CARBON_TYPES = new Set(Object.keys(CARBON_COVERAGE));
8
+ export class AgUiRunError extends Error {
9
+ code;
10
+ constructor(message, code) {
11
+ super(message);
12
+ this.code = code;
13
+ this.name = 'AgUiRunError';
14
+ }
15
+ }
16
+ export class CarbonAgUiAdapter {
17
+ opts;
18
+ threadId;
19
+ state;
20
+ /** Progress/status state per messageId, reconciled from ACTIVITY_SNAPSHOT/ACTIVITY_DELTA. */
21
+ activities = new Map();
22
+ messages = [];
23
+ /** The interrupt awaiting a decision, or undefined when none is pending. */
24
+ pendingInterrupt;
25
+ genId;
26
+ /** The Carbon instance from the most recent run, reused for resume streaming. */
27
+ lastInstance;
28
+ constructor(opts) {
29
+ this.opts = opts;
30
+ this.genId = opts.idGenerator ?? (() => crypto.randomUUID());
31
+ this.threadId = opts.threadId ?? this.genId();
32
+ this.state = opts.initialState ?? {};
33
+ }
34
+ /** Pass as `messaging.customSendMessage` in Carbon's PublicConfig. */
35
+ sendMessage = (req, options, instance) => this.handle(req, options, instance);
36
+ buildRunInput(request) {
37
+ this.upsertMessage({ id: request.id ?? this.genId(), role: 'user', content: request.input.text ?? '' });
38
+ return {
39
+ threadId: this.threadId,
40
+ runId: this.genId(),
41
+ state: this.state,
42
+ messages: [...this.messages],
43
+ tools: this.opts.tools ?? [],
44
+ context: this.opts.context ?? [],
45
+ forwardedProps: this.opts.forwardedProps ?? {},
46
+ };
47
+ }
48
+ reset() {
49
+ this.messages = [];
50
+ this.state = this.opts.initialState ?? {};
51
+ this.pendingInterrupt = undefined;
52
+ }
53
+ /**
54
+ * Resume the interrupted run with the user's decision. Builds a resume
55
+ * `RunAgentInput` per docs/hitl-interrupt-resume.md (same threadId, empty
56
+ * messages, one `resume[]` entry), runs it through the same runner, and
57
+ * streams the continuation back into the conversation.
58
+ *
59
+ * @param instance The Carbon instance to stream into. Defaults to the one
60
+ * from the most recent run (e.g. the interrupted turn).
61
+ */
62
+ async respondToInterrupt(decision, instance) {
63
+ const interrupt = this.pendingInterrupt;
64
+ if (!interrupt)
65
+ throw new Error('respondToInterrupt: no pending interrupt');
66
+ const target = instance ?? this.lastInstance;
67
+ if (!target)
68
+ throw new Error('respondToInterrupt: no Carbon instance to stream into');
69
+ this.pendingInterrupt = undefined;
70
+ const input = {
71
+ threadId: this.threadId,
72
+ runId: this.genId(),
73
+ state: this.state,
74
+ messages: [],
75
+ tools: this.opts.tools ?? [],
76
+ context: this.opts.context ?? [],
77
+ forwardedProps: this.opts.forwardedProps ?? {},
78
+ resume: [decisionToResumeEntry(decision, interrupt.id)],
79
+ };
80
+ await this.runAndStream(input, {}, target);
81
+ }
82
+ upsertMessage(msg) {
83
+ const i = this.messages.findIndex((m) => m.id === msg.id);
84
+ if (i === -1)
85
+ this.messages.push(msg);
86
+ else
87
+ this.messages[i] = msg;
88
+ }
89
+ async handle(request, options, instance) {
90
+ const input = this.buildRunInput(request);
91
+ await this.runAndStream(input, options, instance, request.id);
92
+ }
93
+ /**
94
+ * Run one `RunAgentInput` through the runner and translate its AG-UI events
95
+ * into Carbon chunks on `instance`. Shared by the initial turn and by
96
+ * `respondToInterrupt`, so a resumed continuation streams identically.
97
+ */
98
+ async runAndStream(input, options, instance, requestId) {
99
+ this.lastInstance = instance;
100
+ const { runId } = input;
101
+ const meta = { response_id: runId };
102
+ const emit = (chunk) => instance.messaging.addMessageChunk(chunk);
103
+ const items = [];
104
+ const openText = new Map();
105
+ const openTools = new Map();
106
+ const steps = [];
107
+ const stepByCall = new Map();
108
+ // Chunk variants carry the id only on their first chunk; later chunks continue the current one.
109
+ let lastChunkTextId;
110
+ let lastChunkToolId;
111
+ // Reasoning text accumulates per messageId (THINKING_TEXT_* has none → fixed 'thinking'
112
+ // key). One ReasoningStep per key at close; empties are dropped. Not streamed live.
113
+ const THINKING_KEY = 'thinking';
114
+ const reasoningText = new Map();
115
+ const addReasoning = (key, delta) => reasoningText.set(key, (reasoningText.get(key) ?? '') + delta);
116
+ // Feedback (thumbs) config attaches to completed assistant text items only, so
117
+ // both TEXT_MESSAGE_END and stream-close-finalized text land it in final_response.
118
+ const feedback = this.opts.feedback;
119
+ const textItem = (id, text, stopped = false) => ({
120
+ response_type: 'text',
121
+ text,
122
+ streaming_metadata: stopped ? { id, stream_stopped: true } : { id },
123
+ ...(feedback ? { message_item_options: { feedback } } : {}),
124
+ });
125
+ // Live partials only: flags Carbon's native stop-streaming button. Complete items and
126
+ // the final_response never carry it — cancelling mid-stream is only meaningful while live.
127
+ // No feedback config on partials; it belongs on the completed item Carbon renders.
128
+ const partialTextItem = (id, text) => ({
129
+ response_type: 'text',
130
+ text,
131
+ streaming_metadata: { id, cancellable: true },
132
+ });
133
+ // Finalize one tool call (drained from openTools): record it in history, build the
134
+ // chain-of-thought step, and fire onToolCall. Shared by TOOL_CALL_END and the
135
+ // stream-close drain (chunk variants have no END, so they finalize at close).
136
+ const finalizeToolCall = async (tc) => {
137
+ this.upsertMessage({
138
+ id: tc.parentMessageId ?? tc.id,
139
+ role: 'assistant',
140
+ toolCalls: [{ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.args } }],
141
+ });
142
+ if (this.opts.chainOfThought !== false) {
143
+ const step = { title: tc.name, tool_name: tc.name, request: { args: parseJsonLoose(tc.args) } };
144
+ steps.push(step);
145
+ stepByCall.set(tc.id, step);
146
+ }
147
+ const rendered = await this.opts.onToolCall?.(tc);
148
+ if (rendered) {
149
+ items.push(rendered);
150
+ await emit({ complete_item: rendered, streaming_metadata: meta });
151
+ }
152
+ };
153
+ // Dispatch one CUSTOM-carried Carbon item across the agent → UI trust
154
+ // boundary: validate response_type + required fields (issue gmb9), stamp an
155
+ // id if absent, then render it the same way TEXT_MESSAGE_END renders a
156
+ // completed item. `validate: false` falls back to the pre-gmb9 allowlist-only
157
+ // shallow check so disabling validation is uniform across both seams.
158
+ const dispatchCarbonItem = async (raw) => {
159
+ let item;
160
+ if (this.opts.validate !== false) {
161
+ const result = validateCarbonItem(raw, KNOWN_CARBON_TYPES);
162
+ if (!result.ok) {
163
+ this.opts.onInvalidItem?.(raw, result.reason);
164
+ return;
165
+ }
166
+ item = result.item;
167
+ }
168
+ else {
169
+ if (raw === null || typeof raw !== 'object')
170
+ return;
171
+ item = raw;
172
+ if (typeof item.response_type !== 'string' || !KNOWN_CARBON_TYPES.has(item.response_type))
173
+ return;
174
+ }
175
+ const withId = item.streaming_metadata?.id
176
+ ? item
177
+ : { ...item, streaming_metadata: { ...item.streaming_metadata, id: this.genId() } };
178
+ items.push(withId);
179
+ await emit({ complete_item: withId, streaming_metadata: meta });
180
+ };
181
+ try {
182
+ for await (const rawEv of this.opts.run(input, { signal: options.signal })) {
183
+ // IN seam (issue gmb9): validate against the AG-UI protocol schema before
184
+ // switching. A malformed event is reported and skipped, NEVER thrown —
185
+ // one bad event must not kill the stream. `validate: false` passes through.
186
+ let ev;
187
+ if (this.opts.validate !== false) {
188
+ const result = validateAgUiEvent(rawEv);
189
+ if (!result.ok) {
190
+ this.opts.onInvalidEvent?.(rawEv, result.error);
191
+ continue;
192
+ }
193
+ ev = result.event;
194
+ }
195
+ else {
196
+ ev = rawEv;
197
+ }
198
+ this.opts.onEvent?.(ev);
199
+ switch (ev.type) {
200
+ case 'TEXT_MESSAGE_START':
201
+ openText.set(ev.messageId, '');
202
+ break;
203
+ case 'TEXT_MESSAGE_CONTENT':
204
+ openText.set(ev.messageId, (openText.get(ev.messageId) ?? '') + ev.delta);
205
+ await emit({ partial_item: partialTextItem(ev.messageId, ev.delta), streaming_metadata: meta });
206
+ break;
207
+ case 'TEXT_MESSAGE_END': {
208
+ const text = openText.get(ev.messageId) ?? '';
209
+ openText.delete(ev.messageId);
210
+ const item = textItem(ev.messageId, text);
211
+ items.push(item);
212
+ this.upsertMessage({ id: ev.messageId, role: 'assistant', content: text });
213
+ await emit({ complete_item: item, streaming_metadata: meta });
214
+ break;
215
+ }
216
+ case 'TEXT_MESSAGE_CHUNK': {
217
+ // First chunk carries the id; later chunks omit it and continue the current message.
218
+ const id = ev.messageId ?? lastChunkTextId;
219
+ if (id && !openText.has(id))
220
+ openText.set(id, '');
221
+ lastChunkTextId = id;
222
+ if (ev.delta != null && id) {
223
+ openText.set(id, (openText.get(id) ?? '') + ev.delta);
224
+ await emit({ partial_item: partialTextItem(id, ev.delta), streaming_metadata: meta });
225
+ }
226
+ break;
227
+ }
228
+ case 'TOOL_CALL_START':
229
+ openTools.set(ev.toolCallId, {
230
+ id: ev.toolCallId,
231
+ name: ev.toolCallName,
232
+ args: '',
233
+ parentMessageId: ev.parentMessageId,
234
+ });
235
+ break;
236
+ case 'TOOL_CALL_ARGS': {
237
+ const tc = openTools.get(ev.toolCallId);
238
+ if (tc)
239
+ tc.args += ev.delta;
240
+ break;
241
+ }
242
+ case 'TOOL_CALL_END': {
243
+ const tc = openTools.get(ev.toolCallId);
244
+ if (!tc)
245
+ break;
246
+ openTools.delete(ev.toolCallId);
247
+ await finalizeToolCall(tc);
248
+ break;
249
+ }
250
+ case 'TOOL_CALL_CHUNK': {
251
+ // First chunk carries id + name; later chunks omit them and continue the args.
252
+ const id = ev.toolCallId ?? lastChunkToolId;
253
+ if (ev.toolCallName && id && !openTools.has(id)) {
254
+ openTools.set(id, { id, name: ev.toolCallName, args: '', parentMessageId: ev.parentMessageId });
255
+ }
256
+ lastChunkToolId = id;
257
+ if (ev.delta && id) {
258
+ const tc = openTools.get(id);
259
+ if (tc)
260
+ tc.args += ev.delta;
261
+ }
262
+ // No END event: finalized in the stream-close drain below.
263
+ break;
264
+ }
265
+ case 'TOOL_CALL_RESULT': {
266
+ this.upsertMessage({ id: ev.messageId, role: 'tool', content: ev.content, toolCallId: ev.toolCallId });
267
+ const step = stepByCall.get(ev.toolCallId);
268
+ if (step) {
269
+ step.response = { content: parseJsonLoose(ev.content) };
270
+ step.status = 'success';
271
+ }
272
+ break;
273
+ }
274
+ case 'STATE_SNAPSHOT':
275
+ this.state = ev.snapshot;
276
+ this.opts.onStateChange?.(this.state);
277
+ break;
278
+ case 'STATE_DELTA':
279
+ this.state = applyJsonPatch(this.state, ev.delta);
280
+ this.opts.onStateChange?.(this.state);
281
+ break;
282
+ case 'ACTIVITY_SNAPSHOT':
283
+ // A snapshot is authoritative; it replaces any prior activity for this messageId.
284
+ // ponytail: host renders via onActivity; a Carbon system-message render is a deliberate follow-up.
285
+ this.activities.set(ev.messageId, { activityType: ev.activityType, content: ev.content });
286
+ this.opts.onActivity?.(ev.activityType, ev.content, ev.messageId);
287
+ break;
288
+ case 'ACTIVITY_DELTA': {
289
+ const entry = this.activities.get(ev.messageId) ?? { activityType: ev.activityType, content: {} };
290
+ entry.content = applyJsonPatch(entry.content, ev.patch);
291
+ this.activities.set(ev.messageId, entry);
292
+ this.opts.onActivity?.(entry.activityType, entry.content, ev.messageId);
293
+ break;
294
+ }
295
+ case 'MESSAGES_SNAPSHOT': {
296
+ // A non-streaming graph (e.g. the HITL resume continuation) delivers
297
+ // its assistant reply as a full snapshot rather than TEXT_MESSAGE_*
298
+ // deltas. Render any assistant message that is newly present in this
299
+ // run and wasn't already streamed (streamed messages are already in
300
+ // this.messages via TEXT_MESSAGE_END), so it lands in the conversation.
301
+ const known = new Set(this.messages.map((m) => m.id));
302
+ for (const m of ev.messages) {
303
+ if (m.role !== 'assistant' || !m.content || known.has(m.id))
304
+ continue;
305
+ const item = textItem(m.id, m.content);
306
+ items.push(item);
307
+ await emit({ complete_item: item, streaming_metadata: meta });
308
+ }
309
+ this.messages = [...ev.messages];
310
+ break;
311
+ }
312
+ case 'REASONING_MESSAGE_START':
313
+ reasoningText.set(ev.messageId, reasoningText.get(ev.messageId) ?? '');
314
+ break;
315
+ case 'REASONING_MESSAGE_CONTENT':
316
+ addReasoning(ev.messageId, ev.delta);
317
+ break;
318
+ case 'REASONING_MESSAGE_CHUNK':
319
+ if (ev.messageId != null && ev.delta != null)
320
+ addReasoning(ev.messageId, ev.delta);
321
+ break;
322
+ case 'THINKING_TEXT_MESSAGE_START':
323
+ reasoningText.set(THINKING_KEY, reasoningText.get(THINKING_KEY) ?? '');
324
+ break;
325
+ case 'THINKING_TEXT_MESSAGE_CONTENT':
326
+ addReasoning(THINKING_KEY, ev.delta);
327
+ break;
328
+ case 'REASONING_ENCRYPTED_VALUE':
329
+ // Encrypted reasoning payload; nothing to render.
330
+ break;
331
+ case 'REASONING_START':
332
+ case 'REASONING_END':
333
+ case 'REASONING_MESSAGE_END':
334
+ case 'THINKING_START':
335
+ case 'THINKING_END':
336
+ case 'THINKING_TEXT_MESSAGE_END':
337
+ // Reasoning lifecycle markers; the step is built from accumulated text at close.
338
+ break;
339
+ case 'RUN_ERROR':
340
+ throw new AgUiRunError(ev.message, ev.code);
341
+ case 'RUN_FINISHED': {
342
+ // A HITL interrupt arrives here as `outcome`. Instead of closing the
343
+ // turn as a plain final_response, emit a user_defined decision item so
344
+ // the host can render an approve/reject/edit card, and retain the
345
+ // interrupt for the resume. Non-interrupt outcomes fall through.
346
+ this.opts.onRunFinished?.({ outcome: ev.outcome, result: ev.result });
347
+ const interrupt = firstInterrupt(ev.outcome);
348
+ if (interrupt) {
349
+ this.pendingInterrupt = interrupt;
350
+ const item = interruptItem(interrupt);
351
+ items.push(item);
352
+ await emit({ complete_item: item, streaming_metadata: meta });
353
+ }
354
+ else {
355
+ // The only non-interrupt outcome is `{ type: 'success' }` (or an
356
+ // absent outcome): a clean completion that carries no payload, so
357
+ // the already-streamed content stands and there is nothing extra to
358
+ // render. A host reads any returned `result` via onRunFinished above.
359
+ }
360
+ break;
361
+ }
362
+ case 'CUSTOM':
363
+ // Generative UI: an agent dispatches rendered Carbon items. Only the
364
+ // `carbon.item` / `carbon.items` names carry items; any other CUSTOM
365
+ // name is surfaced via onEvent only (handled by the default arm below).
366
+ if (ev.name === 'carbon.item') {
367
+ await dispatchCarbonItem(ev.value);
368
+ }
369
+ else if (ev.name === 'carbon.items' && Array.isArray(ev.value)) {
370
+ for (const it of ev.value)
371
+ await dispatchCarbonItem(it);
372
+ }
373
+ break;
374
+ default:
375
+ // RUN_STARTED / STEP_* / RAW / other CUSTOM: surfaced via onEvent only.
376
+ break;
377
+ }
378
+ }
379
+ }
380
+ catch (err) {
381
+ if (!options.signal?.aborted)
382
+ throw err;
383
+ }
384
+ // Stream ended (RUN_FINISHED, exhausted, or aborted): close anything still open.
385
+ // Tool chunks have no END, so any still-open tools are finalized here. Non-chunk
386
+ // runs leave openTools empty (TOOL_CALL_END already drained them), so this is a no-op.
387
+ for (const [id, tc] of openTools) {
388
+ openTools.delete(id);
389
+ await finalizeToolCall(tc);
390
+ }
391
+ const stopped = options.signal?.aborted ?? false;
392
+ for (const [id, text] of openText) {
393
+ const item = textItem(id, text, stopped);
394
+ items.push(item);
395
+ this.upsertMessage({ id, role: 'assistant', content: text });
396
+ await emit({ complete_item: item, streaming_metadata: meta });
397
+ }
398
+ // One reasoning step per accumulated key; drop empties. Coexists with chain_of_thought.
399
+ const reasoningSteps = [];
400
+ for (const [, text] of reasoningText) {
401
+ if (text)
402
+ reasoningSteps.push({ title: 'Reasoning', content: text });
403
+ }
404
+ const messageOptions = {
405
+ ...(steps.length ? { chain_of_thought: steps } : {}),
406
+ ...(reasoningSteps.length ? { reasoning: { steps: reasoningSteps } } : {}),
407
+ };
408
+ // ponytail: chain-of-thought/reasoning only land on final_response; attach partial_response to chunks if live status is wanted.
409
+ await emit({
410
+ final_response: {
411
+ id: runId,
412
+ request_id: requestId,
413
+ output: { generic: items },
414
+ ...(Object.keys(messageOptions).length ? { message_options: messageOptions } : {}),
415
+ },
416
+ });
417
+ }
418
+ }
419
+ function parseJsonLoose(s) {
420
+ try {
421
+ return JSON.parse(s);
422
+ }
423
+ catch {
424
+ return s;
425
+ }
426
+ }
427
+ /** The first interrupt in a RUN_FINISHED outcome, or undefined if not an interrupt. */
428
+ function firstInterrupt(outcome) {
429
+ if (outcome?.type !== 'interrupt')
430
+ return undefined;
431
+ return outcome.interrupts?.[0];
432
+ }
433
+ /** Build the Carbon `user_defined` decision-card item from an interrupt. */
434
+ function interruptItem(interrupt) {
435
+ const raw = interrupt.metadata?.langgraph?.raw ?? {};
436
+ const data = {
437
+ kind: 'interrupt',
438
+ interruptId: interrupt.id,
439
+ message: interrupt.message ?? raw.message,
440
+ action: raw.action,
441
+ args: raw.args,
442
+ responseSchema: interrupt.responseSchema,
443
+ toolCallId: interrupt.toolCallId,
444
+ };
445
+ return {
446
+ response_type: 'user_defined',
447
+ user_defined: data,
448
+ streaming_metadata: { id: interrupt.id },
449
+ };
450
+ }
451
+ /** Map a decision to a resume entry per docs/hitl-interrupt-resume.md. */
452
+ function decisionToResumeEntry(decision, interruptId) {
453
+ switch (decision.type) {
454
+ case 'approve':
455
+ return { interruptId, status: 'resolved', payload: { approved: true } };
456
+ case 'edit':
457
+ return { interruptId, status: 'resolved', payload: { approved: true, args: decision.args } };
458
+ case 'reject':
459
+ return { interruptId, status: 'cancelled', payload: null };
460
+ }
461
+ }
462
+ /** Convenience: returns just the customSendMessage function. */
463
+ export function createAgUiSendMessage(opts) {
464
+ return new CarbonAgUiAdapter(opts).sendMessage;
465
+ }
466
+ /** Resolve a JSON Pointer to its `{ parent, key }` within the `{ doc }` working root. */
467
+ function resolvePointer(root, path) {
468
+ if (path !== '' && !path.startsWith('/'))
469
+ throw new Error(`Bad JSON Pointer: ${path}`);
470
+ const tokens = path === '' ? [] : path.slice(1).split('/').map((t) => t.replace(/~1/g, '/').replace(/~0/g, '~'));
471
+ const keys = ['doc', ...tokens];
472
+ const key = keys.pop();
473
+ let parent = root;
474
+ for (const k of keys) {
475
+ if (parent === null || typeof parent !== 'object')
476
+ throw new Error(`Path not found: ${path}`);
477
+ parent = parent[k];
478
+ }
479
+ return { parent, key };
480
+ }
481
+ function getAt(root, path) {
482
+ const { parent, key } = resolvePointer(root, path);
483
+ if (Array.isArray(parent))
484
+ return parent[key === '-' ? parent.length - 1 : Number(key)];
485
+ if (parent !== null && typeof parent === 'object')
486
+ return parent[key];
487
+ throw new Error(`Path not found: ${path}`);
488
+ }
489
+ function addAt(root, path, value) {
490
+ const { parent, key } = resolvePointer(root, path);
491
+ if (Array.isArray(parent))
492
+ parent.splice(key === '-' ? parent.length : Number(key), 0, value);
493
+ else if (parent !== null && typeof parent === 'object')
494
+ parent[key] = value;
495
+ else
496
+ throw new Error(`Path not found: ${path}`);
497
+ }
498
+ function replaceAt(root, path, value) {
499
+ const { parent, key } = resolvePointer(root, path);
500
+ if (Array.isArray(parent))
501
+ parent[Number(key)] = value;
502
+ else if (parent !== null && typeof parent === 'object')
503
+ parent[key] = value;
504
+ else
505
+ throw new Error(`Path not found: ${path}`);
506
+ }
507
+ function removeAt(root, path) {
508
+ const { parent, key } = resolvePointer(root, path);
509
+ if (Array.isArray(parent))
510
+ parent.splice(key === '-' ? parent.length - 1 : Number(key), 1);
511
+ else if (parent !== null && typeof parent === 'object')
512
+ delete parent[key];
513
+ else
514
+ throw new Error(`Path not found: ${path}`);
515
+ }
516
+ // ponytail: hand-rolled RFC 6902 (add/replace/remove/move/copy/test) — zero-dep by design,
517
+ // see the note at the top of types.ts. Swap for fast-json-patch if perf on huge docs matters.
518
+ export function applyJsonPatch(doc, ops) {
519
+ const root = { doc: structuredClone(doc) };
520
+ for (const op of ops) {
521
+ switch (op.op) {
522
+ case 'add':
523
+ addAt(root, op.path, op.value);
524
+ break;
525
+ case 'replace':
526
+ replaceAt(root, op.path, op.value);
527
+ break;
528
+ case 'remove':
529
+ removeAt(root, op.path);
530
+ break;
531
+ case 'move': {
532
+ if (op.from == null)
533
+ throw new Error(`Missing "from" for move: ${op.path}`);
534
+ const value = getAt(root, op.from);
535
+ removeAt(root, op.from);
536
+ addAt(root, op.path, value);
537
+ break;
538
+ }
539
+ case 'copy': {
540
+ if (op.from == null)
541
+ throw new Error(`Missing "from" for copy: ${op.path}`);
542
+ addAt(root, op.path, structuredClone(getAt(root, op.from)));
543
+ break;
544
+ }
545
+ case 'test': {
546
+ const actual = getAt(root, op.path);
547
+ if (JSON.stringify(actual) !== JSON.stringify(op.value)) {
548
+ throw new Error(`JSON Patch test failed: ${op.path}`);
549
+ }
550
+ break;
551
+ }
552
+ default:
553
+ throw new Error(`Unsupported JSON Patch op: ${op.op}`);
554
+ }
555
+ }
556
+ return root.doc;
557
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * - `handled` — the adapter actively translates/emits this.
3
+ * - `observed` — surfaced via lifecycle/onEvent only, no Carbon output by design.
4
+ * - `not-applicable` — out of this adapter's scope, with a reason (counts as green).
5
+ * - `gap` — should be covered, isn't yet (the epic's open work).
6
+ */
7
+ export type CoverageStatus = 'handled' | 'observed' | 'not-applicable' | 'gap';
8
+ export interface CoverageEntry {
9
+ status: CoverageStatus;
10
+ note: string;
11
+ /** kata issue that closes this gap, when status is `gap`. */
12
+ issue?: string;
13
+ }
14
+ /** Green = covered; only `gap` is red. */
15
+ export declare function isGap(e: CoverageEntry): boolean;
16
+ export declare const AG_UI_COVERAGE: Record<string, CoverageEntry>;
17
+ export declare const CARBON_COVERAGE: Record<string, CoverageEntry>;
18
+ export interface CoverageGaps {
19
+ agUi: string[];
20
+ carbon: string[];
21
+ /** Total unresolved gaps across both axes; 0 = epic DoD met. */
22
+ total: number;
23
+ }
24
+ /** List every `gap` entry on each axis. */
25
+ export declare function coverageGaps(): CoverageGaps;
@@ -0,0 +1,82 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Coverage matrix — the epic's definition-of-done gate (issue xte5).
3
+ //
4
+ // Two declared maps: every AG-UI EventType × how the adapter treats it, and
5
+ // every Carbon MessageResponseType × whether the adapter can emit it. The gate
6
+ // (scripts/coverage.ts + test/coverage.test.ts) cross-checks these keys against
7
+ // the *installed* enums (@ag-ui/core EventType, @carbon/ai-chat's declared
8
+ // MessageResponseTypes) so the matrix stays honest as upstream evolves and no
9
+ // entry is hand-invented or left stale.
10
+ //
11
+ // "Done" for the completeness epic = zero `gap` entries (`bun run coverage`
12
+ // exits 0). Builders closing a child issue flip their entries from `gap` to
13
+ // `handled` here — that is how the matrix goes green.
14
+ // ---------------------------------------------------------------------------
15
+ /** Green = covered; only `gap` is red. */
16
+ export function isGap(e) {
17
+ return e.status === 'gap';
18
+ }
19
+ // AG-UI EventType → adapter handling. Keys must equal @ag-ui/core's EventType.
20
+ export const AG_UI_COVERAGE = {
21
+ RUN_STARTED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
22
+ RUN_FINISHED: { status: 'handled', note: 'Closes turn; interrupt outcome → decision card. Non-interrupt outcomes: 9ma7.' },
23
+ RUN_ERROR: { status: 'handled', note: 'Thrown as AgUiRunError.' },
24
+ STEP_STARTED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
25
+ STEP_FINISHED: { status: 'observed', note: 'Lifecycle; onEvent only.' },
26
+ TEXT_MESSAGE_START: { status: 'handled', note: 'Opens a streamed text item.' },
27
+ TEXT_MESSAGE_CONTENT: { status: 'handled', note: 'Partial text chunk.' },
28
+ TEXT_MESSAGE_END: { status: 'handled', note: 'Completes the text item.' },
29
+ TEXT_MESSAGE_CHUNK: { status: 'handled', note: 'Combined text variant; first chunk carries id, deltas streamed, finalized at close.' },
30
+ TOOL_CALL_START: { status: 'handled', note: 'Opens a tool call.' },
31
+ TOOL_CALL_ARGS: { status: 'handled', note: 'Accumulates tool args.' },
32
+ TOOL_CALL_END: { status: 'handled', note: 'Chain-of-thought step + onToolCall hook.' },
33
+ TOOL_CALL_CHUNK: { status: 'handled', note: 'Combined tool variant; first chunk carries id+name, args accumulated, finalized at close.' },
34
+ TOOL_CALL_RESULT: { status: 'handled', note: 'Fills the chain-of-thought step response.' },
35
+ STATE_SNAPSHOT: { status: 'handled', note: 'Replaces adapter state.' },
36
+ STATE_DELTA: { status: 'handled', note: 'Full RFC 6902 JSON-Patch (add/replace/remove/move/copy/test).' },
37
+ MESSAGES_SNAPSHOT: { status: 'handled', note: 'Renders newly-present assistant messages.' },
38
+ THINKING_START: { status: 'handled', note: 'Deprecated reasoning-start alias; lifecycle marker.' },
39
+ THINKING_END: { status: 'handled', note: 'Deprecated reasoning-end alias; lifecycle marker.' },
40
+ THINKING_TEXT_MESSAGE_START: { status: 'handled', note: 'Opens the implicit thinking reasoning step.' },
41
+ THINKING_TEXT_MESSAGE_CONTENT: { status: 'handled', note: 'Accumulates thinking text (no messageId).' },
42
+ THINKING_TEXT_MESSAGE_END: { status: 'handled', note: 'Closes the thinking reasoning step.' },
43
+ REASONING_START: { status: 'handled', note: 'Reasoning lifecycle marker.' },
44
+ REASONING_END: { status: 'handled', note: 'Reasoning lifecycle marker.' },
45
+ REASONING_MESSAGE_START: { status: 'handled', note: 'Opens a reasoning step per messageId.' },
46
+ REASONING_MESSAGE_CONTENT: { status: 'handled', note: 'Accumulates reasoning text.' },
47
+ REASONING_MESSAGE_END: { status: 'handled', note: 'Closes a reasoning step; rendered on final_response.' },
48
+ REASONING_MESSAGE_CHUNK: { status: 'handled', note: 'Combined reasoning variant; delta accumulated.' },
49
+ REASONING_ENCRYPTED_VALUE: { status: 'handled', note: 'Encrypted; no displayable text, explicit no-op.' },
50
+ ACTIVITY_SNAPSHOT: { status: 'handled', note: 'Reconciled into activities map (authoritative); exposed via onActivity.' },
51
+ ACTIVITY_DELTA: { status: 'handled', note: 'JSON-Patch applied onto activity content; exposed via onActivity.' },
52
+ RAW: { status: 'observed', note: 'Passthrough; onEvent only.' },
53
+ CUSTOM: { status: 'handled', note: "Dispatched as rendered Carbon items via name 'carbon.item'/'carbon.items'." },
54
+ };
55
+ // Carbon MessageResponseType → can the adapter emit it. Keys must equal
56
+ // @carbon/ai-chat's MessageResponseTypes enum values.
57
+ export const CARBON_COVERAGE = {
58
+ text: { status: 'handled', note: 'Streamed + complete text items.' },
59
+ user_defined: { status: 'handled', note: 'HITL decision card; host payload passthrough.' },
60
+ connect_to_agent: { status: 'not-applicable', note: 'Human-agent escalation needs a service desk; out of adapter scope.' },
61
+ option: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonOptionItem, shape compile-proven in carbon-compat.ts.' },
62
+ image: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonImageItem, shape compile-proven in carbon-compat.ts.' },
63
+ video: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonVideoItem, shape compile-proven in carbon-compat.ts.' },
64
+ audio: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonAudioItem, shape compile-proven in carbon-compat.ts.' },
65
+ iframe: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonIFrameItem, shape compile-proven in carbon-compat.ts.' },
66
+ date: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonDateItem, shape compile-proven in carbon-compat.ts.' },
67
+ card: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonCardItem, shape compile-proven in carbon-compat.ts.' },
68
+ carousel: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonCarouselItem, shape compile-proven in carbon-compat.ts.' },
69
+ button: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonButtonItem, shape compile-proven in carbon-compat.ts.' },
70
+ grid: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonGridItem, shape compile-proven in carbon-compat.ts.' },
71
+ preview_card: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonPreviewCardItem, shape compile-proven in carbon-compat.ts.' },
72
+ conversational_search: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonConversationalSearchItem, shape compile-proven in carbon-compat.ts.' },
73
+ pause: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonPauseItem, shape compile-proven in carbon-compat.ts.' },
74
+ inline_error: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonInlineErrorItem, shape compile-proven in carbon-compat.ts.' },
75
+ system: { status: 'handled', note: 'Emittable via CUSTOM carbon.item dispatch; typed as CarbonSystemMessageItem, shape compile-proven in carbon-compat.ts.' },
76
+ };
77
+ /** List every `gap` entry on each axis. */
78
+ export function coverageGaps() {
79
+ const agUi = Object.entries(AG_UI_COVERAGE).filter(([, e]) => isGap(e)).map(([k]) => k);
80
+ const carbon = Object.entries(CARBON_COVERAGE).filter(([, e]) => isGap(e)).map(([k]) => k);
81
+ return { agUi, carbon, total: agUi.length + carbon.length };
82
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './adapter.js';
3
+ export * from './transport.js';
4
+ export * from './coverage.js';
5
+ export * from './validate.js';