@opengeni/react 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,632 @@
1
+ import type { ResourceRef, SessionEvent, SessionStatus, ToolRef } from "@opengeni/sdk";
2
+ import { stringifyPayload, tryParseJson } from "./lib/format";
3
+
4
+ /* ----------------------------------------------------------------------------
5
+ Timeline projection
6
+
7
+ `buildTimeline` folds a session's raw event log (replayed + live, ordered by
8
+ sequence) into renderable items: chat messages with accumulated streaming
9
+ deltas, reasoning summaries, tool calls matched to their outputs, sandbox
10
+ operations with command output, spawned-worker status (the manager's
11
+ `session_create` / `session_send_message` orchestration calls), goal
12
+ markers, status changes, and turn failures.
13
+
14
+ It is a pure function — same events in, same items out — so it can be
15
+ memoized, unit-tested, and re-run incrementally as new events stream in.
16
+ -------------------------------------------------------------------------- */
17
+
18
+ export type UserMessageItem = {
19
+ kind: "user-message";
20
+ id: string;
21
+ text: string;
22
+ /** Resources attached to this message (file uploads, repositories). */
23
+ resources: ResourceRef[];
24
+ /** Tools requested for the turn this message starts. */
25
+ tools: ToolRef[];
26
+ occurredAt: string;
27
+ };
28
+
29
+ export type AgentMessageItem = {
30
+ kind: "agent-message";
31
+ id: string;
32
+ turnId: string | null;
33
+ text: string;
34
+ /** Still receiving deltas (no completed/turn-end seen yet). */
35
+ streaming: boolean;
36
+ occurredAt: string;
37
+ };
38
+
39
+ export type ReasoningItem = {
40
+ kind: "reasoning";
41
+ id: string;
42
+ turnId: string | null;
43
+ text: string;
44
+ streaming: boolean;
45
+ occurredAt: string;
46
+ };
47
+
48
+ export type ToolCallItem = {
49
+ kind: "tool-call";
50
+ id: string;
51
+ turnId: string | null;
52
+ callId: string | null;
53
+ name: string;
54
+ arguments: unknown;
55
+ output: unknown;
56
+ status: "running" | "complete";
57
+ occurredAt: string;
58
+ };
59
+
60
+ /**
61
+ * An orchestration call against another session — the manager spawning or
62
+ * messaging a worker. Rendered as a first-class "worker" row, not a generic
63
+ * tool call.
64
+ */
65
+ export type WorkerItem = {
66
+ kind: "worker";
67
+ id: string;
68
+ turnId: string | null;
69
+ callId: string | null;
70
+ action: "spawn" | "message";
71
+ /** The worker's initial message / the message sent to it, when parseable. */
72
+ prompt: string | null;
73
+ /** The target/spawned worker session id, when parseable from args/output. */
74
+ workerSessionId: string | null;
75
+ status: "running" | "complete";
76
+ occurredAt: string;
77
+ };
78
+
79
+ export type SandboxItem = {
80
+ kind: "sandbox";
81
+ id: string;
82
+ turnId: string | null;
83
+ name: string;
84
+ command: string | null;
85
+ output: string;
86
+ status: "running" | "complete" | "failed";
87
+ occurredAt: string;
88
+ };
89
+
90
+ export type SessionStatusItem = {
91
+ kind: "session-status";
92
+ id: string;
93
+ status: SessionStatus;
94
+ occurredAt: string;
95
+ };
96
+
97
+ export type GoalItem = {
98
+ kind: "goal";
99
+ id: string;
100
+ action: "set" | "updated" | "completed" | "paused" | "resumed" | "continuation";
101
+ text: string | null;
102
+ occurredAt: string;
103
+ };
104
+
105
+ export type NoticeItem = {
106
+ kind: "notice";
107
+ id: string;
108
+ tone: "waiting" | "cancelled" | "failed";
109
+ text: string;
110
+ occurredAt: string;
111
+ };
112
+
113
+ export type TimelineItem =
114
+ | UserMessageItem
115
+ | AgentMessageItem
116
+ | ReasoningItem
117
+ | ToolCallItem
118
+ | WorkerItem
119
+ | SandboxItem
120
+ | SessionStatusItem
121
+ | GoalItem
122
+ | NoticeItem;
123
+
124
+ /** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
125
+ const WORKER_SPAWN_TOOL = "session_create";
126
+ const WORKER_MESSAGE_TOOL = "session_send_message";
127
+
128
+ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
129
+ const items: TimelineItem[] = [];
130
+ const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
131
+
132
+ const last = (): TimelineItem | undefined => items[items.length - 1];
133
+
134
+ /** A new item of a different kind ends whatever was streaming at the tail. */
135
+ const closeStreamingTail = (): void => {
136
+ const open = last();
137
+ if ((open?.kind === "agent-message" || open?.kind === "reasoning") && open.streaming) {
138
+ open.streaming = false;
139
+ }
140
+ };
141
+
142
+ const finalizeOpen = (turnId?: string | null): void => {
143
+ for (const item of items) {
144
+ if (turnId !== undefined && "turnId" in item && item.turnId && turnId && item.turnId !== turnId) {
145
+ continue;
146
+ }
147
+ if ((item.kind === "agent-message" || item.kind === "reasoning") && item.streaming) {
148
+ item.streaming = false;
149
+ }
150
+ if ((item.kind === "tool-call" || item.kind === "worker") && item.status === "running") {
151
+ item.status = "complete";
152
+ }
153
+ if (item.kind === "sandbox" && item.status === "running") {
154
+ item.status = "complete";
155
+ }
156
+ }
157
+ };
158
+
159
+ for (const event of ordered) {
160
+ const payload = asRecord(event.payload);
161
+ const turnId = event.turnId ?? null;
162
+
163
+ switch (event.type) {
164
+ case "user.message": {
165
+ // A steering message must not mark in-flight tools complete; it only
166
+ // ends whatever text was streaming. Turn lifecycle events finalize.
167
+ closeStreamingTail();
168
+ items.push({
169
+ kind: "user-message",
170
+ id: event.id,
171
+ text: typeof payload.text === "string" ? payload.text : "",
172
+ resources: resourceRefs(payload.resources),
173
+ tools: toolRefs(payload.tools),
174
+ occurredAt: event.occurredAt,
175
+ });
176
+ break;
177
+ }
178
+
179
+ case "agent.message.delta": {
180
+ const text = typeof payload.text === "string" ? payload.text : "";
181
+ if (!text) {
182
+ break;
183
+ }
184
+ const open = last();
185
+ if (open?.kind === "agent-message" && open.streaming && open.turnId === turnId) {
186
+ open.text += text;
187
+ break;
188
+ }
189
+ closeStreamingTail();
190
+ items.push({
191
+ kind: "agent-message",
192
+ id: event.id,
193
+ turnId,
194
+ text,
195
+ streaming: true,
196
+ occurredAt: event.occurredAt,
197
+ });
198
+ break;
199
+ }
200
+
201
+ case "agent.message.completed": {
202
+ const text = typeof payload.text === "string" ? payload.text : "";
203
+ // Reconcile the most recent same-turn agent message — even when
204
+ // activity (tool calls, reasoning) landed after its deltas — so the
205
+ // completed text never duplicates the streamed one.
206
+ const open = [...items]
207
+ .reverse()
208
+ .find((item): item is AgentMessageItem => item.kind === "agent-message" && item.turnId === turnId);
209
+ if (open && (open.streaming || !open.text || text === open.text || text.startsWith(open.text))) {
210
+ // The completed text is authoritative when it extends what streamed.
211
+ if (!open.text || (text && text.startsWith(open.text))) {
212
+ open.text = text || open.text;
213
+ }
214
+ open.streaming = false;
215
+ break;
216
+ }
217
+ if (text) {
218
+ items.push({
219
+ kind: "agent-message",
220
+ id: event.id,
221
+ turnId,
222
+ text,
223
+ streaming: false,
224
+ occurredAt: event.occurredAt,
225
+ });
226
+ }
227
+ break;
228
+ }
229
+
230
+ case "agent.reasoning.delta": {
231
+ const text = reasoningText(event.payload);
232
+ if (!text) {
233
+ break;
234
+ }
235
+ const open = last();
236
+ if (open?.kind === "reasoning" && open.streaming && open.turnId === turnId) {
237
+ open.text += text;
238
+ break;
239
+ }
240
+ closeStreamingTail();
241
+ items.push({
242
+ kind: "reasoning",
243
+ id: event.id,
244
+ turnId,
245
+ text,
246
+ streaming: true,
247
+ occurredAt: event.occurredAt,
248
+ });
249
+ break;
250
+ }
251
+
252
+ case "agent.toolCall.created": {
253
+ const name = typeof payload.name === "string" ? payload.name : "tool";
254
+ const callId = typeof payload.id === "string" ? payload.id : null;
255
+ const args = payload.arguments ?? null;
256
+ closeStreamingTail();
257
+ if (name === WORKER_SPAWN_TOOL || name === WORKER_MESSAGE_TOOL) {
258
+ items.push({
259
+ kind: "worker",
260
+ id: event.id,
261
+ turnId,
262
+ callId,
263
+ action: name === WORKER_SPAWN_TOOL ? "spawn" : "message",
264
+ prompt: workerPrompt(args),
265
+ workerSessionId: extractSessionRef(args),
266
+ status: "running",
267
+ occurredAt: event.occurredAt,
268
+ });
269
+ break;
270
+ }
271
+ items.push({
272
+ kind: "tool-call",
273
+ id: event.id,
274
+ turnId,
275
+ callId,
276
+ name,
277
+ arguments: args,
278
+ output: undefined,
279
+ status: "running",
280
+ occurredAt: event.occurredAt,
281
+ });
282
+ break;
283
+ }
284
+
285
+ case "agent.toolCall.output": {
286
+ const callId = typeof payload.id === "string" ? payload.id : null;
287
+ const target = findOpenCall(items, callId);
288
+ if (!target) {
289
+ break;
290
+ }
291
+ if (target.kind === "worker") {
292
+ target.status = "complete";
293
+ target.workerSessionId = target.workerSessionId ?? extractSessionRef(payload.output);
294
+ break;
295
+ }
296
+ target.status = "complete";
297
+ target.output = payload.output;
298
+ break;
299
+ }
300
+
301
+ case "sandbox.operation.started":
302
+ case "sandbox.operation.completed":
303
+ case "sandbox.operation.failed": {
304
+ const name = typeof payload.name === "string" ? payload.name : "sandbox";
305
+ const status = event.type.endsWith(".failed") ? "failed" : event.type.endsWith(".completed") ? "complete" : "running";
306
+ const existing = findOpenSandbox(items, name);
307
+ if (existing && status !== "running") {
308
+ existing.status = status;
309
+ const message = failureMessage(payload);
310
+ if (message) {
311
+ existing.output = existing.output ? `${existing.output}\n${message}` : message;
312
+ }
313
+ break;
314
+ }
315
+ if (!existing) {
316
+ closeStreamingTail();
317
+ items.push({
318
+ kind: "sandbox",
319
+ id: event.id,
320
+ turnId,
321
+ name,
322
+ command: typeof payload.command === "string" ? payload.command : null,
323
+ output: failureMessage(payload) ?? "",
324
+ status,
325
+ occurredAt: event.occurredAt,
326
+ });
327
+ }
328
+ break;
329
+ }
330
+
331
+ case "sandbox.command.output.delta": {
332
+ const text = typeof payload.text === "string" ? payload.text : typeof payload.output === "string" ? payload.output : "";
333
+ if (!text) {
334
+ break;
335
+ }
336
+ // Attach to the named operation when the payload carries one;
337
+ // otherwise the latest running operation is the best available owner.
338
+ const open =
339
+ (typeof payload.name === "string" ? findOpenSandbox(items, payload.name) : undefined) ??
340
+ [...items].reverse().find((item): item is SandboxItem => item.kind === "sandbox" && item.status === "running");
341
+ if (open) {
342
+ open.output += text;
343
+ }
344
+ break;
345
+ }
346
+
347
+ case "session.status.changed": {
348
+ const status = payload.status;
349
+ if (!isSessionStatus(status)) {
350
+ break;
351
+ }
352
+ const previous = [...items].reverse().find((item): item is SessionStatusItem => item.kind === "session-status");
353
+ if (previous?.status === status) {
354
+ break;
355
+ }
356
+ items.push({ kind: "session-status", id: event.id, status, occurredAt: event.occurredAt });
357
+ break;
358
+ }
359
+
360
+ case "session.requiresAction": {
361
+ finalizeOpen(turnId);
362
+ items.push({
363
+ kind: "notice",
364
+ id: event.id,
365
+ tone: "waiting",
366
+ text: "Approval needed — the turn is paused until someone decides.",
367
+ occurredAt: event.occurredAt,
368
+ });
369
+ break;
370
+ }
371
+
372
+ case "turn.completed": {
373
+ finalizeOpen(turnId);
374
+ break;
375
+ }
376
+
377
+ case "turn.failed": {
378
+ finalizeOpen(turnId);
379
+ items.push({
380
+ kind: "notice",
381
+ id: event.id,
382
+ tone: "failed",
383
+ text: failureMessage(payload) ?? "The turn failed.",
384
+ occurredAt: event.occurredAt,
385
+ });
386
+ break;
387
+ }
388
+
389
+ case "turn.cancelled": {
390
+ finalizeOpen(turnId);
391
+ items.push({
392
+ kind: "notice",
393
+ id: event.id,
394
+ tone: "cancelled",
395
+ text: "Interrupted.",
396
+ occurredAt: event.occurredAt,
397
+ });
398
+ break;
399
+ }
400
+
401
+ case "goal.set":
402
+ case "goal.updated":
403
+ case "goal.completed":
404
+ case "goal.paused":
405
+ case "goal.resumed":
406
+ case "goal.continuation": {
407
+ items.push({
408
+ kind: "goal",
409
+ id: event.id,
410
+ action: event.type.slice("goal.".length) as GoalItem["action"],
411
+ text: goalText(payload),
412
+ occurredAt: event.occurredAt,
413
+ });
414
+ break;
415
+ }
416
+
417
+ default:
418
+ break;
419
+ }
420
+ }
421
+
422
+ return items;
423
+ }
424
+
425
+ /** The latest session status carried in the event log, if any. */
426
+ export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus | null {
427
+ for (let index = events.length - 1; index >= 0; index -= 1) {
428
+ const event = events[index];
429
+ if (event?.type !== "session.status.changed") {
430
+ continue;
431
+ }
432
+ const status = asRecord(event.payload).status;
433
+ if (isSessionStatus(status)) {
434
+ return status;
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
440
+ /* ----------------------------------------------------------------------------
441
+ Visual grouping: consecutive activity items (reasoning / tools / workers /
442
+ sandbox) cluster into one collapsible block between chat messages.
443
+ -------------------------------------------------------------------------- */
444
+
445
+ export type TimelineGroup =
446
+ | { kind: "item"; item: TimelineItem }
447
+ | { kind: "activity"; id: string; items: (ReasoningItem | ToolCallItem | WorkerItem | SandboxItem)[] };
448
+
449
+ const ACTIVITY_KINDS = new Set(["reasoning", "tool-call", "worker", "sandbox"]);
450
+
451
+ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
452
+ const groups: TimelineGroup[] = [];
453
+ for (const item of items) {
454
+ if (ACTIVITY_KINDS.has(item.kind)) {
455
+ const open = groups[groups.length - 1];
456
+ const activity = item as ReasoningItem | ToolCallItem | WorkerItem | SandboxItem;
457
+ if (open?.kind === "activity") {
458
+ open.items.push(activity);
459
+ } else {
460
+ groups.push({ kind: "activity", id: `activity-${item.id}`, items: [activity] });
461
+ }
462
+ continue;
463
+ }
464
+ groups.push({ kind: "item", item });
465
+ }
466
+ return groups;
467
+ }
468
+
469
+ /* --- helpers ---------------------------------------------------------------- */
470
+
471
+ function asRecord(value: unknown): Record<string, unknown> {
472
+ return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
473
+ }
474
+
475
+ const SESSION_STATUSES: readonly SessionStatus[] = ["queued", "running", "idle", "requires_action", "failed", "cancelled"];
476
+
477
+ /** Keep only entries that match the wire shapes; user payloads are untyped. */
478
+ function resourceRefs(value: unknown): ResourceRef[] {
479
+ if (!Array.isArray(value)) {
480
+ return [];
481
+ }
482
+ return value.filter((entry): entry is ResourceRef => {
483
+ const record = asRecord(entry);
484
+ if (record.kind === "repository") {
485
+ return typeof record.uri === "string" && typeof record.ref === "string";
486
+ }
487
+ return record.kind === "file" && typeof record.fileId === "string";
488
+ });
489
+ }
490
+
491
+ function toolRefs(value: unknown): ToolRef[] {
492
+ if (!Array.isArray(value)) {
493
+ return [];
494
+ }
495
+ return value.filter((entry): entry is ToolRef => {
496
+ const record = asRecord(entry);
497
+ return record.kind === "mcp" && typeof record.id === "string";
498
+ });
499
+ }
500
+
501
+ function isSessionStatus(value: unknown): value is SessionStatus {
502
+ return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
503
+ }
504
+
505
+ function findOpenCall(items: TimelineItem[], callId: string | null): ToolCallItem | WorkerItem | undefined {
506
+ const reversed = [...items].reverse();
507
+ const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem => item.kind === "tool-call" || item.kind === "worker";
508
+ if (callId) {
509
+ const byId = reversed.find((item) => isCall(item) && item.callId === callId);
510
+ if (byId) {
511
+ return byId as ToolCallItem | WorkerItem;
512
+ }
513
+ }
514
+ return reversed.find((item): item is ToolCallItem | WorkerItem => isCall(item) && item.status === "running");
515
+ }
516
+
517
+ function findOpenSandbox(items: TimelineItem[], name: string): SandboxItem | undefined {
518
+ return [...items].reverse().find((item): item is SandboxItem => item.kind === "sandbox" && item.name === name && item.status === "running");
519
+ }
520
+
521
+ function failureMessage(payload: Record<string, unknown>): string | null {
522
+ for (const key of ["error", "message"] as const) {
523
+ const value = payload[key];
524
+ if (typeof value === "string" && value.trim().length > 0) {
525
+ return value;
526
+ }
527
+ }
528
+ return null;
529
+ }
530
+
531
+ function goalText(payload: Record<string, unknown>): string | null {
532
+ if (typeof payload.text === "string" && payload.text) {
533
+ return payload.text;
534
+ }
535
+ const goal = asRecord(payload.goal);
536
+ if (typeof goal.text === "string" && goal.text) {
537
+ return goal.text;
538
+ }
539
+ if (typeof payload.prompt === "string" && payload.prompt) {
540
+ return payload.prompt;
541
+ }
542
+ return null;
543
+ }
544
+
545
+ function reasoningText(payload: unknown): string {
546
+ const record = asRecord(payload);
547
+ if (typeof record.text === "string") {
548
+ return record.text;
549
+ }
550
+ const content = asRecord(asRecord(record.item).rawItem).content;
551
+ if (!Array.isArray(content)) {
552
+ return "";
553
+ }
554
+ return content
555
+ .map((part) => {
556
+ const text = asRecord(part).text;
557
+ return typeof text === "string" ? text : "";
558
+ })
559
+ .join("");
560
+ }
561
+
562
+ /** The worker's initial/sent message from `session_create`/`session_send_message` args. */
563
+ function workerPrompt(args: unknown): string | null {
564
+ const record = asRecord(typeof args === "string" ? tryParseJson(args) : args);
565
+ for (const key of ["initialMessage", "message", "text", "prompt"] as const) {
566
+ const value = record[key];
567
+ if (typeof value === "string" && value.trim().length > 0) {
568
+ return value;
569
+ }
570
+ }
571
+ return null;
572
+ }
573
+
574
+ /**
575
+ * Find a session id in orchestration tool arguments or output. Handles raw
576
+ * objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
577
+ * text: "{...}" }], structuredContent? }`).
578
+ */
579
+ export function extractSessionRef(value: unknown, depth = 0): string | null {
580
+ if (depth > 6 || value === null || value === undefined) {
581
+ return null;
582
+ }
583
+ if (typeof value === "string") {
584
+ return extractSessionRef(tryParseJson(value), depth + 1);
585
+ }
586
+ if (Array.isArray(value)) {
587
+ for (const entry of value) {
588
+ const found = extractSessionRef(entry, depth + 1);
589
+ if (found) {
590
+ return found;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+ if (typeof value !== "object") {
596
+ return null;
597
+ }
598
+ const record = value as Record<string, unknown>;
599
+ if (typeof record.sessionId === "string" && looksLikeId(record.sessionId)) {
600
+ return record.sessionId;
601
+ }
602
+ if (typeof record.id === "string" && looksLikeId(record.id) && ("status" in record || "workspaceId" in record || "initialMessage" in record)) {
603
+ return record.id;
604
+ }
605
+ for (const key of ["structuredContent", "session", "result", "content"] as const) {
606
+ if (key in record) {
607
+ const found = extractSessionRef(record[key], depth + 1);
608
+ if (found) {
609
+ return found;
610
+ }
611
+ }
612
+ }
613
+ if (typeof record.text === "string") {
614
+ return extractSessionRef(tryParseJson(record.text), depth + 1);
615
+ }
616
+ return null;
617
+ }
618
+
619
+ function looksLikeId(value: string): boolean {
620
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
621
+ }
622
+
623
+ /** Readable label for a tool call ("session_create" -> "session create"). */
624
+ export function toolDisplayName(name: string): string {
625
+ return name.replace(/[_-]+/g, " ").trim();
626
+ }
627
+
628
+ /** Compact, single-line preview of tool arguments/outputs for collapsed rows. */
629
+ export function compactPayloadPreview(value: unknown, maxLength = 120): string {
630
+ const text = stringifyPayload(value).replace(/\s+/g, " ").trim();
631
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
632
+ }