@opengeni/react 0.11.0 → 0.13.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.
- package/README.md +1 -1
- package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
- package/dist/chunk-NFYVQWIB.js.map +1 -0
- package/dist/index.d.ts +791 -145
- package/dist/index.js +5447 -2213
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +17 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +32 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +135 -39
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +16 -4
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-metrics.tsx +31 -8
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines-dashboard.tsx +8 -2
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/sandbox-files.tsx +110 -227
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +823 -0
- package/src/components/session-status.tsx +28 -2
- package/src/components/workbench-changes.tsx +541 -0
- package/src/components/workspace-dock.tsx +85 -22
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +57 -36
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +49 -21
- package/src/hooks/use-session-events.ts +48 -20
- package/src/hooks/use-session-lineage.ts +119 -0
- package/src/hooks/use-session.ts +43 -28
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +74 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +128 -23
- package/src/lib/format.ts +44 -6
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +13 -3
- package/src/provider.tsx +3 -1
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +44 -10
- package/src/timeline/projection.ts +435 -84
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/tokens.css +2 -2
- package/dist/chunk-5C7RGAWA.js.map +0 -1
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CREDIT_EXHAUSTION_MESSAGE,
|
|
4
|
+
humanizeFailureReason,
|
|
5
|
+
isCreditExhaustion,
|
|
6
|
+
tryParseJson,
|
|
7
|
+
} from "../lib/format";
|
|
3
8
|
import type {
|
|
4
9
|
AgentMessageItem,
|
|
5
10
|
ActivityItem,
|
|
11
|
+
AuthNeededItem,
|
|
6
12
|
GoalItem,
|
|
13
|
+
MemoryItem,
|
|
7
14
|
SandboxItem,
|
|
8
15
|
SessionStatusItem,
|
|
9
16
|
TimelineGroup,
|
|
@@ -30,7 +37,6 @@ import type {
|
|
|
30
37
|
/** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
|
|
31
38
|
const WORKER_SPAWN_TOOL = "session_create";
|
|
32
39
|
const WORKER_MESSAGE_TOOL = "session_send_message";
|
|
33
|
-
const WORKER_INTERRUPT_TOOL = "session_interrupt";
|
|
34
40
|
|
|
35
41
|
export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
36
42
|
const items: TimelineItem[] = [];
|
|
@@ -47,9 +53,18 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
47
53
|
}
|
|
48
54
|
};
|
|
49
55
|
|
|
50
|
-
const finalizeOpen = (
|
|
56
|
+
const finalizeOpen = (
|
|
57
|
+
turnId?: string | null,
|
|
58
|
+
disposition: "complete" | "failed" | "cancelled" = "complete",
|
|
59
|
+
): void => {
|
|
51
60
|
for (const item of items) {
|
|
52
|
-
if (
|
|
61
|
+
if (
|
|
62
|
+
turnId !== undefined &&
|
|
63
|
+
"turnId" in item &&
|
|
64
|
+
item.turnId &&
|
|
65
|
+
turnId &&
|
|
66
|
+
item.turnId !== turnId
|
|
67
|
+
) {
|
|
53
68
|
continue;
|
|
54
69
|
}
|
|
55
70
|
if ((item.kind === "agent-message" || item.kind === "reasoning") && item.streaming) {
|
|
@@ -73,11 +88,27 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
73
88
|
// A steering message must not mark in-flight tools complete; it only
|
|
74
89
|
// ends whatever text was streaming. Turn lifecycle events finalize.
|
|
75
90
|
closeStreamingTail();
|
|
91
|
+
const childCompletion = workerCompletionPayload(payload.childCompletion);
|
|
92
|
+
if (childCompletion) {
|
|
93
|
+
items.push({
|
|
94
|
+
kind: "worker-completion",
|
|
95
|
+
id: event.id,
|
|
96
|
+
turnId,
|
|
97
|
+
occurredAt: event.occurredAt,
|
|
98
|
+
childSessionId: childCompletion.childSessionId,
|
|
99
|
+
childStatus: childCompletion.childStatus,
|
|
100
|
+
goalStatus: childCompletion.goalStatus,
|
|
101
|
+
goalText: childCompletion.goalText,
|
|
102
|
+
evidence: childCompletion.evidence,
|
|
103
|
+
pausedReason: childCompletion.pausedReason,
|
|
104
|
+
text: typeof payload.text === "string" ? payload.text : "",
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
76
108
|
items.push({
|
|
77
109
|
kind: "user-message",
|
|
78
110
|
id: event.id,
|
|
79
111
|
text: typeof payload.text === "string" ? payload.text : "",
|
|
80
|
-
...(event.pendingUserMessage ? { pending: true } : {}),
|
|
81
112
|
resources: resourceRefs(payload.resources),
|
|
82
113
|
tools: toolRefs(payload.tools),
|
|
83
114
|
occurredAt: event.occurredAt,
|
|
@@ -114,8 +145,14 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
114
145
|
// completed text never duplicates the streamed one.
|
|
115
146
|
const open = [...items]
|
|
116
147
|
.reverse()
|
|
117
|
-
.find(
|
|
118
|
-
|
|
148
|
+
.find(
|
|
149
|
+
(item): item is AgentMessageItem =>
|
|
150
|
+
item.kind === "agent-message" && item.turnId === turnId,
|
|
151
|
+
);
|
|
152
|
+
if (
|
|
153
|
+
open &&
|
|
154
|
+
(open.streaming || !open.text || text === open.text || text.startsWith(open.text))
|
|
155
|
+
) {
|
|
119
156
|
// The completed text is authoritative when it extends what streamed.
|
|
120
157
|
if (!open.text || (text && text.startsWith(open.text))) {
|
|
121
158
|
open.text = text || open.text;
|
|
@@ -163,23 +200,6 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
163
200
|
const callId = typeof payload.id === "string" ? payload.id : null;
|
|
164
201
|
const args = payload.arguments ?? null;
|
|
165
202
|
closeStreamingTail();
|
|
166
|
-
if (name === WORKER_INTERRUPT_TOOL) {
|
|
167
|
-
// stop (default) vs steer — the target keeps its goal on steer and
|
|
168
|
-
// picks up its next queued turn (pair with session_send_message).
|
|
169
|
-
items.push({
|
|
170
|
-
kind: "worker",
|
|
171
|
-
id: event.id,
|
|
172
|
-
turnId,
|
|
173
|
-
callId,
|
|
174
|
-
action: "interrupt",
|
|
175
|
-
prompt: null,
|
|
176
|
-
workerSessionId: extractSessionRef(args),
|
|
177
|
-
mode: workerInterruptMode(args),
|
|
178
|
-
status: "running",
|
|
179
|
-
occurredAt: event.occurredAt,
|
|
180
|
-
});
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
203
|
if (name === WORKER_SPAWN_TOOL || name === WORKER_MESSAGE_TOOL) {
|
|
184
204
|
items.push({
|
|
185
205
|
kind: "worker",
|
|
@@ -235,10 +255,37 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
235
255
|
case "sandbox.operation.completed":
|
|
236
256
|
case "sandbox.operation.failed": {
|
|
237
257
|
const name = typeof payload.name === "string" ? payload.name : "sandbox";
|
|
238
|
-
const status = event.type.endsWith(".failed")
|
|
258
|
+
const status = event.type.endsWith(".failed")
|
|
259
|
+
? "failed"
|
|
260
|
+
: event.type.endsWith(".completed")
|
|
261
|
+
? "complete"
|
|
262
|
+
: "running";
|
|
263
|
+
// Routine per-turn platform plumbing that runs before EVERY turn to
|
|
264
|
+
// guarantee box contents survive a re-warm — NOT the agent redoing work:
|
|
265
|
+
// - repository-clone: idempotent clone check + off-manifest token re-seed;
|
|
266
|
+
// - file-resource-download: idempotent `if [ ! -f ] then curl` (skips
|
|
267
|
+
// when the attached file is already on the box — see
|
|
268
|
+
// sandboxFileDownloadCommand), so an uploaded image is not re-fetched;
|
|
269
|
+
// the operation still emits every turn even when it does nothing.
|
|
270
|
+
// Rendering either every turn reads as churn. Only FAILURES surface, and
|
|
271
|
+
// they surface loudly — the failed event below creates its own item even
|
|
272
|
+
// without a started row.
|
|
273
|
+
if (
|
|
274
|
+
(name === "repository-clone" || name === "file-resource-download") &&
|
|
275
|
+
status !== "failed"
|
|
276
|
+
) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
239
279
|
const existing = findOpenSandbox(items, name);
|
|
240
280
|
if (existing && status !== "running") {
|
|
241
281
|
existing.status = status;
|
|
282
|
+
if (
|
|
283
|
+
payload.origin === "created" ||
|
|
284
|
+
payload.origin === "restored" ||
|
|
285
|
+
payload.origin === "resumed"
|
|
286
|
+
) {
|
|
287
|
+
existing.origin = payload.origin;
|
|
288
|
+
}
|
|
242
289
|
const message = failureMessage(payload);
|
|
243
290
|
if (message) {
|
|
244
291
|
existing.output = existing.output ? `${existing.output}\n${message}` : message;
|
|
@@ -254,6 +301,12 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
254
301
|
name,
|
|
255
302
|
command: typeof payload.command === "string" ? payload.command : null,
|
|
256
303
|
output: failureMessage(payload) ?? "",
|
|
304
|
+
origin:
|
|
305
|
+
payload.origin === "created" ||
|
|
306
|
+
payload.origin === "restored" ||
|
|
307
|
+
payload.origin === "resumed"
|
|
308
|
+
? payload.origin
|
|
309
|
+
: null,
|
|
257
310
|
status,
|
|
258
311
|
occurredAt: event.occurredAt,
|
|
259
312
|
});
|
|
@@ -263,9 +316,14 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
263
316
|
|
|
264
317
|
case "sandbox.command.output.delta": {
|
|
265
318
|
// `chunk` is the canonical wire field; text/output are legacy shapes.
|
|
266
|
-
const text =
|
|
267
|
-
|
|
268
|
-
|
|
319
|
+
const text =
|
|
320
|
+
typeof payload.chunk === "string"
|
|
321
|
+
? payload.chunk
|
|
322
|
+
: typeof payload.text === "string"
|
|
323
|
+
? payload.text
|
|
324
|
+
: typeof payload.output === "string"
|
|
325
|
+
? payload.output
|
|
326
|
+
: "";
|
|
269
327
|
if (!text) {
|
|
270
328
|
break;
|
|
271
329
|
}
|
|
@@ -273,7 +331,11 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
273
331
|
// otherwise the latest running operation is the best available owner.
|
|
274
332
|
const open =
|
|
275
333
|
(typeof payload.name === "string" ? findOpenSandbox(items, payload.name) : undefined) ??
|
|
276
|
-
[...items]
|
|
334
|
+
[...items]
|
|
335
|
+
.reverse()
|
|
336
|
+
.find(
|
|
337
|
+
(item): item is SandboxItem => item.kind === "sandbox" && item.status === "running",
|
|
338
|
+
);
|
|
277
339
|
if (open) {
|
|
278
340
|
open.output += text;
|
|
279
341
|
}
|
|
@@ -293,7 +355,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
293
355
|
if (!ATTENTION_STATUSES.has(status)) {
|
|
294
356
|
break;
|
|
295
357
|
}
|
|
296
|
-
const previous = [...items]
|
|
358
|
+
const previous = [...items]
|
|
359
|
+
.reverse()
|
|
360
|
+
.find((item): item is SessionStatusItem => item.kind === "session-status");
|
|
297
361
|
if (previous?.status === status) {
|
|
298
362
|
break;
|
|
299
363
|
}
|
|
@@ -313,21 +377,112 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
313
377
|
break;
|
|
314
378
|
}
|
|
315
379
|
|
|
316
|
-
case "
|
|
380
|
+
case "session.context.compacted": {
|
|
381
|
+
closeStreamingTail();
|
|
382
|
+
const before =
|
|
383
|
+
typeof payload.estimatedTokensBefore === "number"
|
|
384
|
+
? Math.round(payload.estimatedTokensBefore).toLocaleString("en-US")
|
|
385
|
+
: null;
|
|
386
|
+
const after =
|
|
387
|
+
typeof payload.estimatedTokensAfter === "number"
|
|
388
|
+
? Math.round(payload.estimatedTokensAfter).toLocaleString("en-US")
|
|
389
|
+
: null;
|
|
390
|
+
items.push({
|
|
391
|
+
kind: "notice",
|
|
392
|
+
id: event.id,
|
|
393
|
+
tone: "waiting",
|
|
394
|
+
text:
|
|
395
|
+
before && after
|
|
396
|
+
? `Context compacted from approximately ${before} to ${after} tokens.`
|
|
397
|
+
: "Context compacted so the turn could continue.",
|
|
398
|
+
occurredAt: event.occurredAt,
|
|
399
|
+
});
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case "session.context.compaction.skipped": {
|
|
317
404
|
closeStreamingTail();
|
|
318
|
-
const
|
|
405
|
+
const reason = typeof payload.reason === "string" ? payload.reason : null;
|
|
319
406
|
items.push({
|
|
320
407
|
kind: "notice",
|
|
321
408
|
id: event.id,
|
|
322
409
|
tone: "waiting",
|
|
323
|
-
text:
|
|
324
|
-
|
|
410
|
+
text:
|
|
411
|
+
reason === "no_history"
|
|
412
|
+
? "Context compaction skipped because there is no active history to compact."
|
|
413
|
+
: reason === "replacement_not_smaller"
|
|
414
|
+
? "Context compaction skipped because the generated checkpoint would not reduce the context."
|
|
415
|
+
: "Context compaction was not needed.",
|
|
416
|
+
occurredAt: event.occurredAt,
|
|
417
|
+
});
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
case "turn.recovery.requested": {
|
|
422
|
+
// Recovery requests are durable control-plane evidence, not a user
|
|
423
|
+
// message or proof that recovery succeeded. Live state already exposes
|
|
424
|
+
// a genuinely recovering session; keep the raw event in Debug/audit.
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
case "turn.event.rejected_late": {
|
|
429
|
+
// Attempt-fence rejections prove stale callbacks did not alter current
|
|
430
|
+
// truth. They are useful diagnostics but never actionable chat content,
|
|
431
|
+
// regardless of the rejected event type. Debug/audit retains the event.
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case "tool.auth_needed": {
|
|
436
|
+
// Keep the whole structured payload — the renderer turns it into a clean
|
|
437
|
+
// inline reconnect card, and the app starts the recovery flow off the
|
|
438
|
+
// connectionId/resource. Losing it to a plain-text notice was the ugly
|
|
439
|
+
// "linear.app needs to be reconnected." line users complained about.
|
|
440
|
+
closeStreamingTail();
|
|
441
|
+
items.push({
|
|
442
|
+
kind: "auth-needed",
|
|
443
|
+
id: event.id,
|
|
444
|
+
turnId,
|
|
445
|
+
providerDomain: typeof payload.providerDomain === "string" ? payload.providerDomain : "",
|
|
446
|
+
connectionId: typeof payload.connectionId === "string" ? payload.connectionId : null,
|
|
447
|
+
reason: authNeededReason(payload.reason),
|
|
448
|
+
scopes: stringList(payload.scopes),
|
|
449
|
+
resource: typeof payload.resource === "string" ? payload.resource : null,
|
|
450
|
+
toolName: typeof payload.toolName === "string" ? payload.toolName : null,
|
|
451
|
+
authorizationUrl:
|
|
452
|
+
typeof payload.authorizationUrl === "string" ? payload.authorizationUrl : null,
|
|
325
453
|
occurredAt: event.occurredAt,
|
|
326
454
|
});
|
|
327
455
|
break;
|
|
328
456
|
}
|
|
329
457
|
|
|
330
458
|
case "turn.completed": {
|
|
459
|
+
// A standalone manual compaction uses the turn ledger for fencing and
|
|
460
|
+
// recovery, but it is maintenance rather than a conversational turn.
|
|
461
|
+
// The dedicated session.context.compacted notice is the complete UI
|
|
462
|
+
// truth; adding a generic turn chip would falsely make it look like an
|
|
463
|
+
// extra agent response.
|
|
464
|
+
if (payload.maintenance === "context_compaction") {
|
|
465
|
+
finalizeOpen(turnId);
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
// Credit exhaustion arrives as a NOMINALLY completed turn (`detail:
|
|
469
|
+
// "insufficient OpenGeni credits"`, `segmentLimit: "budget_exhausted"`)
|
|
470
|
+
// — the engine ended the segment early, it did not finish the work.
|
|
471
|
+
// Rendering it as a clean "complete" turn is a lie that leaves the
|
|
472
|
+
// session looking healthy while every future turn silently dies, so it
|
|
473
|
+
// projects exactly like a failed turn plus an explicit notice.
|
|
474
|
+
if (isCreditExhaustionPayload(payload)) {
|
|
475
|
+
finalizeOpen(turnId);
|
|
476
|
+
items.push(turnEndItem(event, "failed", CREDIT_EXHAUSTION_MESSAGE));
|
|
477
|
+
items.push({
|
|
478
|
+
kind: "notice",
|
|
479
|
+
id: event.id,
|
|
480
|
+
tone: "failed",
|
|
481
|
+
text: CREDIT_EXHAUSTION_MESSAGE,
|
|
482
|
+
occurredAt: event.occurredAt,
|
|
483
|
+
});
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
331
486
|
finalizeOpen(turnId);
|
|
332
487
|
items.push(turnEndItem(event, "complete", null));
|
|
333
488
|
break;
|
|
@@ -335,7 +490,12 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
335
490
|
|
|
336
491
|
case "turn.failed": {
|
|
337
492
|
const hadActivity = hasTurnActivity(items, turnId);
|
|
338
|
-
|
|
493
|
+
// Credit death can hide behind fields `failureMessage` doesn't read
|
|
494
|
+
// (detail/segmentLimit), so classify the whole payload before falling
|
|
495
|
+
// back to the generic error/message extraction.
|
|
496
|
+
const failureText = isCreditExhaustionPayload(payload)
|
|
497
|
+
? CREDIT_EXHAUSTION_MESSAGE
|
|
498
|
+
: failureMessage(payload);
|
|
339
499
|
// The TURN failed — the in-flight items did not. Chip doctrine: red is
|
|
340
500
|
// spent once, on the turn-level outcome. Items caught mid-flight read
|
|
341
501
|
// as calm "interrupted" (same as turn.cancelled); an item that itself
|
|
@@ -376,11 +536,25 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
376
536
|
break;
|
|
377
537
|
}
|
|
378
538
|
|
|
539
|
+
case "memory.saved":
|
|
540
|
+
case "memory.corrected": {
|
|
541
|
+
// A first-party memory write is a discrete step, not streamed text, so it
|
|
542
|
+
// ends whatever was streaming (mirrors tool/sandbox pushes). A payload
|
|
543
|
+
// missing the memory id is malformed and dropped rather than shown blank.
|
|
544
|
+
const memory = memoryItem(event.id, event.type, turnId, payload, event.occurredAt);
|
|
545
|
+
if (memory) {
|
|
546
|
+
closeStreamingTail();
|
|
547
|
+
items.push(memory);
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
|
|
379
552
|
case "goal.set":
|
|
380
553
|
case "goal.updated":
|
|
381
554
|
case "goal.completed":
|
|
382
555
|
case "goal.paused":
|
|
383
556
|
case "goal.resumed":
|
|
557
|
+
case "goal.cleared":
|
|
384
558
|
case "goal.continuation": {
|
|
385
559
|
items.push({
|
|
386
560
|
kind: "goal",
|
|
@@ -400,6 +574,37 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
|
|
|
400
574
|
return items;
|
|
401
575
|
}
|
|
402
576
|
|
|
577
|
+
/** The turn-end payload shape, as `isCreditExhaustion` wants it. */
|
|
578
|
+
function isCreditExhaustionPayload(payload: Record<string, unknown>): boolean {
|
|
579
|
+
return isCreditExhaustion({
|
|
580
|
+
error: typeof payload.error === "string" ? payload.error : null,
|
|
581
|
+
detail: typeof payload.detail === "string" ? payload.detail : null,
|
|
582
|
+
segmentLimit: typeof payload.segmentLimit === "string" ? payload.segmentLimit : null,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Whether the session's most recent turn ended in credit exhaustion — the
|
|
588
|
+
* terminal credit state apps key their "add credits" affordances on. Derived
|
|
589
|
+
* from the LAST turn-end event (completed/failed/cancelled): a later turn that
|
|
590
|
+
* settles any other way (someone topped up and kept working) clears it.
|
|
591
|
+
*/
|
|
592
|
+
export function creditExhaustedFromEvents(events: SessionEvent[]): boolean {
|
|
593
|
+
const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
|
|
594
|
+
for (let index = ordered.length - 1; index >= 0; index -= 1) {
|
|
595
|
+
const event = ordered[index];
|
|
596
|
+
if (
|
|
597
|
+
event?.type !== "turn.completed" &&
|
|
598
|
+
event?.type !== "turn.failed" &&
|
|
599
|
+
event?.type !== "turn.cancelled"
|
|
600
|
+
) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
return isCreditExhaustionPayload(asRecord(event.payload));
|
|
604
|
+
}
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
|
|
403
608
|
/** The latest session status carried in the event log, if any. */
|
|
404
609
|
export function sessionStatusFromEvents(events: SessionEvent[]): SessionStatus | null {
|
|
405
610
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
@@ -432,6 +637,7 @@ function isActivityItem(item: TimelineItem): item is ActivityItem {
|
|
|
432
637
|
case "tool-call":
|
|
433
638
|
case "worker":
|
|
434
639
|
case "sandbox":
|
|
640
|
+
case "memory":
|
|
435
641
|
return true;
|
|
436
642
|
default:
|
|
437
643
|
return false;
|
|
@@ -462,8 +668,6 @@ export function groupTimeline(items: TimelineItem[]): TimelineGroup[] {
|
|
|
462
668
|
|
|
463
669
|
/* --- helpers ---------------------------------------------------------------- */
|
|
464
670
|
|
|
465
|
-
type TimelineEvent = SessionEvent & { pendingUserMessage?: true };
|
|
466
|
-
|
|
467
671
|
type TurnAnchorPrescan = {
|
|
468
672
|
queuedTurnByTrigger: Map<string, string>;
|
|
469
673
|
startSeqByTrigger: Map<string, number>;
|
|
@@ -483,7 +687,8 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
|
|
|
483
687
|
const payload = asRecord(event.payload);
|
|
484
688
|
const turnId = event.turnId ?? null;
|
|
485
689
|
if (event.type === "turn.queued") {
|
|
486
|
-
const triggerEventId =
|
|
690
|
+
const triggerEventId =
|
|
691
|
+
typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
|
|
487
692
|
const queuedTurnId = typeof payload.turnId === "string" ? payload.turnId : turnId;
|
|
488
693
|
if (triggerEventId && queuedTurnId) {
|
|
489
694
|
queuedTurnByTrigger.set(triggerEventId, queuedTurnId);
|
|
@@ -491,8 +696,9 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
|
|
|
491
696
|
continue;
|
|
492
697
|
}
|
|
493
698
|
if (event.type === "turn.started") {
|
|
494
|
-
const triggerEventId =
|
|
495
|
-
|
|
699
|
+
const triggerEventId =
|
|
700
|
+
typeof payload.triggerEventId === "string" ? payload.triggerEventId : null;
|
|
701
|
+
if (triggerEventId && !startSeqByTrigger.has(triggerEventId)) {
|
|
496
702
|
startSeqByTrigger.set(triggerEventId, event.sequence);
|
|
497
703
|
}
|
|
498
704
|
if (turnId) {
|
|
@@ -524,7 +730,11 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
|
|
|
524
730
|
|
|
525
731
|
const cancelledBeforeStartTriggers = new Set<string>();
|
|
526
732
|
for (const [triggerEventId, turnId] of queuedTurnByTrigger) {
|
|
527
|
-
if (
|
|
733
|
+
if (
|
|
734
|
+
cancelledTurnIds.has(turnId) &&
|
|
735
|
+
!startSeqByTrigger.has(triggerEventId) &&
|
|
736
|
+
!fallbackSeqByTurn.has(turnId)
|
|
737
|
+
) {
|
|
528
738
|
cancelledBeforeStartTriggers.add(triggerEventId);
|
|
529
739
|
}
|
|
530
740
|
}
|
|
@@ -532,10 +742,9 @@ function prescanTurnAnchors(events: SessionEvent[]): TurnAnchorPrescan {
|
|
|
532
742
|
return { queuedTurnByTrigger, startSeqByTrigger, cancelledBeforeStartTriggers, startedTurnIds };
|
|
533
743
|
}
|
|
534
744
|
|
|
535
|
-
function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan):
|
|
745
|
+
function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan): SessionEvent[] {
|
|
536
746
|
const ordered = [...events].sort((a, b) => a.sequence - b.sequence);
|
|
537
|
-
const insertions = new Map<number,
|
|
538
|
-
const pending: TimelineEvent[] = [];
|
|
747
|
+
const insertions = new Map<number, SessionEvent[]>();
|
|
539
748
|
|
|
540
749
|
for (const event of ordered) {
|
|
541
750
|
if (event.type !== "user.message") {
|
|
@@ -554,10 +763,12 @@ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan)
|
|
|
554
763
|
pushInsertion(insertions, startSeq, event);
|
|
555
764
|
continue;
|
|
556
765
|
}
|
|
557
|
-
|
|
766
|
+
// A waiting prompt belongs only in the prompt queue. It enters the
|
|
767
|
+
// timeline at turn.started (or the first same-turn activity fallback),
|
|
768
|
+
// never as a second queued representation.
|
|
558
769
|
}
|
|
559
770
|
|
|
560
|
-
const projected:
|
|
771
|
+
const projected: SessionEvent[] = [];
|
|
561
772
|
for (const event of ordered) {
|
|
562
773
|
const before = insertions.get(event.sequence);
|
|
563
774
|
if (before) {
|
|
@@ -567,11 +778,14 @@ function orderTimelineEvents(events: SessionEvent[], prescan: TurnAnchorPrescan)
|
|
|
567
778
|
projected.push(event);
|
|
568
779
|
}
|
|
569
780
|
}
|
|
570
|
-
projected.push(...pending);
|
|
571
781
|
return projected;
|
|
572
782
|
}
|
|
573
783
|
|
|
574
|
-
function pushInsertion(
|
|
784
|
+
function pushInsertion(
|
|
785
|
+
insertions: Map<number, SessionEvent[]>,
|
|
786
|
+
sequence: number,
|
|
787
|
+
event: SessionEvent,
|
|
788
|
+
): void {
|
|
575
789
|
const bucket = insertions.get(sequence);
|
|
576
790
|
if (bucket) {
|
|
577
791
|
bucket.push(event);
|
|
@@ -633,7 +847,10 @@ function stampTurnOutcome(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
|
|
|
633
847
|
}
|
|
634
848
|
}
|
|
635
849
|
|
|
636
|
-
function applyTurnOutcome(
|
|
850
|
+
function applyTurnOutcome(
|
|
851
|
+
group: Extract<TimelineGroup, { kind: "activity" }>,
|
|
852
|
+
turnEnd: TurnEndItem,
|
|
853
|
+
): void {
|
|
637
854
|
// A sub-cluster reports ITS OWN outcome, not the turn's. When a turn fails
|
|
638
855
|
// at step 7, clusters 1–6 completed — painting them all red says "everything
|
|
639
856
|
// broke" when one thing did. The turn-level fold carries the turn outcome;
|
|
@@ -644,7 +861,9 @@ function applyTurnOutcome(group: Extract<TimelineGroup, { kind: "activity" }>, t
|
|
|
644
861
|
return;
|
|
645
862
|
}
|
|
646
863
|
const hasFailed = group.items.some((item) => "status" in item && item.status === "failed");
|
|
647
|
-
const hasInterrupted = group.items.some(
|
|
864
|
+
const hasInterrupted = group.items.some(
|
|
865
|
+
(item) => "status" in item && item.status === "cancelled",
|
|
866
|
+
);
|
|
648
867
|
group.outcome = hasFailed ? "failed" : hasInterrupted ? "cancelled" : "complete";
|
|
649
868
|
if (turnEnd.failureText && hasFailed) {
|
|
650
869
|
group.failureText = turnEnd.failureText;
|
|
@@ -695,7 +914,11 @@ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
|
|
|
695
914
|
turnGroup.failureText = turnEnd.failureText;
|
|
696
915
|
}
|
|
697
916
|
|
|
698
|
-
groups.splice(
|
|
917
|
+
groups.splice(
|
|
918
|
+
startIndex,
|
|
919
|
+
collected.length,
|
|
920
|
+
...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]),
|
|
921
|
+
);
|
|
699
922
|
}
|
|
700
923
|
|
|
701
924
|
function isTurnBoundary(group: TimelineGroup | undefined): boolean {
|
|
@@ -707,16 +930,31 @@ function belongsToDifferentTurn(group: TimelineGroup | undefined, turnId: string
|
|
|
707
930
|
return false;
|
|
708
931
|
}
|
|
709
932
|
if (group.kind === "activity") {
|
|
710
|
-
return
|
|
933
|
+
return (
|
|
934
|
+
group.items.length > 0 &&
|
|
935
|
+
group.items.every((item) => item.turnId !== null && item.turnId !== turnId)
|
|
936
|
+
);
|
|
711
937
|
}
|
|
712
|
-
return
|
|
938
|
+
return (
|
|
939
|
+
group.kind === "item" &&
|
|
940
|
+
group.item.kind === "agent-message" &&
|
|
941
|
+
group.item.turnId !== null &&
|
|
942
|
+
group.item.turnId !== turnId
|
|
943
|
+
);
|
|
713
944
|
}
|
|
714
945
|
|
|
715
946
|
function isBetweenTurnDivider(group: TimelineGroup | undefined): boolean {
|
|
716
|
-
return
|
|
947
|
+
return (
|
|
948
|
+
group?.kind === "item" &&
|
|
949
|
+
group.item.kind === "session-status" &&
|
|
950
|
+
group.item.status !== "running"
|
|
951
|
+
);
|
|
717
952
|
}
|
|
718
953
|
|
|
719
|
-
function extractFinalAgentMessage(
|
|
954
|
+
function extractFinalAgentMessage(
|
|
955
|
+
groups: TimelineGroup[],
|
|
956
|
+
turnEnd: TurnEndItem,
|
|
957
|
+
): Extract<TimelineGroup, { kind: "item" }> | null {
|
|
720
958
|
const tail = groups[groups.length - 1];
|
|
721
959
|
if (tail?.kind !== "item" || tail.item.kind !== "agent-message" || tail.item.streaming) {
|
|
722
960
|
return null;
|
|
@@ -745,10 +983,21 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
745
983
|
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
746
984
|
}
|
|
747
985
|
|
|
748
|
-
const SESSION_STATUSES: readonly SessionStatus[] = [
|
|
986
|
+
const SESSION_STATUSES: readonly SessionStatus[] = [
|
|
987
|
+
"queued",
|
|
988
|
+
"running",
|
|
989
|
+
"idle",
|
|
990
|
+
"requires_action",
|
|
991
|
+
"failed",
|
|
992
|
+
"cancelled",
|
|
993
|
+
];
|
|
749
994
|
|
|
750
995
|
/** Statuses that demand the reader's attention and so earn a timeline divider. */
|
|
751
|
-
const ATTENTION_STATUSES: ReadonlySet<SessionStatus> = new Set([
|
|
996
|
+
const ATTENTION_STATUSES: ReadonlySet<SessionStatus> = new Set([
|
|
997
|
+
"requires_action",
|
|
998
|
+
"failed",
|
|
999
|
+
"cancelled",
|
|
1000
|
+
]);
|
|
752
1001
|
|
|
753
1002
|
/** Keep only entries that match the wire shapes; user payloads are untyped. */
|
|
754
1003
|
function resourceRefs(value: unknown): import("@opengeni/sdk").ResourceRef[] {
|
|
@@ -774,6 +1023,43 @@ function toolRefs(value: unknown): import("@opengeni/sdk").ToolRef[] {
|
|
|
774
1023
|
});
|
|
775
1024
|
}
|
|
776
1025
|
|
|
1026
|
+
function workerCompletionPayload(value: unknown): {
|
|
1027
|
+
childSessionId: string;
|
|
1028
|
+
childStatus: string;
|
|
1029
|
+
goalStatus: string | null;
|
|
1030
|
+
goalText: string | null;
|
|
1031
|
+
evidence: string | null;
|
|
1032
|
+
pausedReason: string | null;
|
|
1033
|
+
} | null {
|
|
1034
|
+
const payload = asRecord(value);
|
|
1035
|
+
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1036
|
+
if (
|
|
1037
|
+
typeof payload.childSessionId !== "string" ||
|
|
1038
|
+
!uuidPattern.test(payload.childSessionId) ||
|
|
1039
|
+
typeof payload.status !== "string" ||
|
|
1040
|
+
payload.status.trim() === ""
|
|
1041
|
+
) {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
const goal = asRecord(payload.goal);
|
|
1045
|
+
return {
|
|
1046
|
+
childSessionId: payload.childSessionId,
|
|
1047
|
+
childStatus: payload.status,
|
|
1048
|
+
goalStatus: typeof goal.status === "string" ? goal.status : null,
|
|
1049
|
+
goalText: typeof goal.text === "string" ? goal.text : null,
|
|
1050
|
+
evidence: typeof goal.evidence === "string" ? goal.evidence : null,
|
|
1051
|
+
// Console pauses and several server paths put the human explanation on
|
|
1052
|
+
// `rationale`, not `pausedReason` — same fallback the goal pill uses, so a
|
|
1053
|
+
// paused worker card never shows "Worker paused" with the reason missing.
|
|
1054
|
+
pausedReason:
|
|
1055
|
+
typeof goal.pausedReason === "string"
|
|
1056
|
+
? goal.pausedReason
|
|
1057
|
+
: typeof goal.rationale === "string"
|
|
1058
|
+
? goal.rationale
|
|
1059
|
+
: null,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
777
1063
|
function isSessionStatus(value: unknown): value is SessionStatus {
|
|
778
1064
|
return typeof value === "string" && (SESSION_STATUSES as readonly string[]).includes(value);
|
|
779
1065
|
}
|
|
@@ -784,23 +1070,36 @@ function isErrorOutput(payload: Record<string, unknown>): boolean {
|
|
|
784
1070
|
return true;
|
|
785
1071
|
}
|
|
786
1072
|
const output = payload.output;
|
|
787
|
-
return
|
|
1073
|
+
return (
|
|
1074
|
+
!!output && typeof output === "object" && (output as { isError?: unknown }).isError === true
|
|
1075
|
+
);
|
|
788
1076
|
}
|
|
789
1077
|
|
|
790
|
-
function findOpenCall(
|
|
1078
|
+
function findOpenCall(
|
|
1079
|
+
items: TimelineItem[],
|
|
1080
|
+
callId: string | null,
|
|
1081
|
+
): ToolCallItem | WorkerItem | undefined {
|
|
791
1082
|
const reversed = [...items].reverse();
|
|
792
|
-
const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem =>
|
|
1083
|
+
const isCall = (item: TimelineItem): item is ToolCallItem | WorkerItem =>
|
|
1084
|
+
item.kind === "tool-call" || item.kind === "worker";
|
|
793
1085
|
if (callId) {
|
|
794
1086
|
const byId = reversed.find((item) => isCall(item) && item.callId === callId);
|
|
795
1087
|
if (byId) {
|
|
796
1088
|
return byId as ToolCallItem | WorkerItem;
|
|
797
1089
|
}
|
|
798
1090
|
}
|
|
799
|
-
return reversed.find(
|
|
1091
|
+
return reversed.find(
|
|
1092
|
+
(item): item is ToolCallItem | WorkerItem => isCall(item) && item.status === "running",
|
|
1093
|
+
);
|
|
800
1094
|
}
|
|
801
1095
|
|
|
802
1096
|
function findOpenSandbox(items: TimelineItem[], name: string): SandboxItem | undefined {
|
|
803
|
-
return [...items]
|
|
1097
|
+
return [...items]
|
|
1098
|
+
.reverse()
|
|
1099
|
+
.find(
|
|
1100
|
+
(item): item is SandboxItem =>
|
|
1101
|
+
item.kind === "sandbox" && item.name === name && item.status === "running",
|
|
1102
|
+
);
|
|
804
1103
|
}
|
|
805
1104
|
|
|
806
1105
|
function failureMessage(payload: Record<string, unknown>): string | null {
|
|
@@ -829,20 +1128,61 @@ function goalText(payload: Record<string, unknown>): string | null {
|
|
|
829
1128
|
return null;
|
|
830
1129
|
}
|
|
831
1130
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1131
|
+
/**
|
|
1132
|
+
* Fold a `memory.saved` / `memory.corrected` event into a {@link MemoryItem}.
|
|
1133
|
+
* Reads DEFENSIVELY (the payload is untyped `unknown`, no Zod schema): a missing
|
|
1134
|
+
* memory id means a malformed event, so we return null and the case drops it.
|
|
1135
|
+
*/
|
|
1136
|
+
function memoryItem(
|
|
1137
|
+
id: string,
|
|
1138
|
+
type: string,
|
|
1139
|
+
turnId: string | null,
|
|
1140
|
+
payload: Record<string, unknown>,
|
|
1141
|
+
occurredAt: string,
|
|
1142
|
+
): MemoryItem | null {
|
|
1143
|
+
const memoryId =
|
|
1144
|
+
typeof payload.memoryId === "string" && payload.memoryId ? payload.memoryId : null;
|
|
1145
|
+
if (!memoryId) {
|
|
1146
|
+
return null;
|
|
844
1147
|
}
|
|
845
|
-
|
|
1148
|
+
const replacementPreview =
|
|
1149
|
+
typeof payload.replacementPreview === "string" ? payload.replacementPreview : undefined;
|
|
1150
|
+
const replacementMemoryId =
|
|
1151
|
+
typeof payload.replacementMemoryId === "string" ? payload.replacementMemoryId : undefined;
|
|
1152
|
+
const action = typeof payload.action === "string" ? payload.action : undefined;
|
|
1153
|
+
return {
|
|
1154
|
+
kind: "memory",
|
|
1155
|
+
id,
|
|
1156
|
+
turnId,
|
|
1157
|
+
variant: type === "memory.corrected" ? "corrected" : "saved",
|
|
1158
|
+
memoryKind: typeof payload.kind === "string" ? payload.kind : "",
|
|
1159
|
+
preview: typeof payload.preview === "string" ? payload.preview : "",
|
|
1160
|
+
...(payload.deduped === true ? { deduped: true } : {}),
|
|
1161
|
+
...(replacementPreview ? { replacementPreview } : {}),
|
|
1162
|
+
...(action ? { action } : {}),
|
|
1163
|
+
memoryId,
|
|
1164
|
+
...(replacementMemoryId ? { replacementMemoryId } : {}),
|
|
1165
|
+
occurredAt,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const AUTH_NEEDED_REASONS: ReadonlySet<string> = new Set([
|
|
1170
|
+
"missing_connection",
|
|
1171
|
+
"expired",
|
|
1172
|
+
"insufficient_scope",
|
|
1173
|
+
"refresh_failed",
|
|
1174
|
+
]);
|
|
1175
|
+
|
|
1176
|
+
function authNeededReason(value: unknown): AuthNeededItem["reason"] {
|
|
1177
|
+
return typeof value === "string" && AUTH_NEEDED_REASONS.has(value)
|
|
1178
|
+
? (value as AuthNeededItem["reason"])
|
|
1179
|
+
: null;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function stringList(value: unknown): string[] {
|
|
1183
|
+
return Array.isArray(value)
|
|
1184
|
+
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
1185
|
+
: [];
|
|
846
1186
|
}
|
|
847
1187
|
|
|
848
1188
|
function reasoningText(payload: unknown): string {
|
|
@@ -874,12 +1214,6 @@ function workerPrompt(args: unknown): string | null {
|
|
|
874
1214
|
return null;
|
|
875
1215
|
}
|
|
876
1216
|
|
|
877
|
-
/** The interrupt mode from `session_interrupt` args; defaults to "stop". */
|
|
878
|
-
function workerInterruptMode(args: unknown): "stop" | "steer" {
|
|
879
|
-
const record = asRecord(typeof args === "string" ? tryParseJson(args) : args);
|
|
880
|
-
return record.mode === "steer" ? "steer" : "stop";
|
|
881
|
-
}
|
|
882
|
-
|
|
883
1217
|
/**
|
|
884
1218
|
* Find a session id in orchestration tool arguments or output. Handles raw
|
|
885
1219
|
* objects, JSON strings, and MCP tool results (`{ content: [{ type: "text",
|
|
@@ -908,7 +1242,11 @@ export function extractSessionRef(value: unknown, depth = 0): string | null {
|
|
|
908
1242
|
if (typeof record.sessionId === "string" && looksLikeId(record.sessionId)) {
|
|
909
1243
|
return record.sessionId;
|
|
910
1244
|
}
|
|
911
|
-
if (
|
|
1245
|
+
if (
|
|
1246
|
+
typeof record.id === "string" &&
|
|
1247
|
+
looksLikeId(record.id) &&
|
|
1248
|
+
("status" in record || "workspaceId" in record || "initialMessage" in record)
|
|
1249
|
+
) {
|
|
912
1250
|
return record.id;
|
|
913
1251
|
}
|
|
914
1252
|
for (const key of ["structuredContent", "session", "result", "content"] as const) {
|
|
@@ -929,7 +1267,20 @@ function looksLikeId(value: string): boolean {
|
|
|
929
1267
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
930
1268
|
}
|
|
931
1269
|
|
|
932
|
-
/**
|
|
1270
|
+
/**
|
|
1271
|
+
* Readable label for a tool call ("session_create" -> "session create").
|
|
1272
|
+
*
|
|
1273
|
+
* MCP tools are namespaced `<serverId>__<toolName>` (see prefixedMcpToolName),
|
|
1274
|
+
* and for catalog-imported servers that serverId is an opaque slug+hash
|
|
1275
|
+
* ("mcp-integrations-sh-supabase-com-34ed9dcf1390-0i6tcf8"). De-slugging the
|
|
1276
|
+
* whole thing leaked that id into the timeline; strip the server prefix and show
|
|
1277
|
+
* just the tool ("list organizations"). Names without the `__` boundary (plain
|
|
1278
|
+
* built-ins like "session_create") are unaffected.
|
|
1279
|
+
*/
|
|
933
1280
|
export function toolDisplayName(name: string): string {
|
|
934
|
-
|
|
1281
|
+
// The prefix is a single LEFT boundary (`registryId__toolName`), so split on
|
|
1282
|
+
// the FIRST `__` — the tool name itself may contain `__` and must survive whole.
|
|
1283
|
+
const boundary = name.indexOf("__");
|
|
1284
|
+
const toolPart = boundary >= 0 ? name.slice(boundary + 2) : name;
|
|
1285
|
+
return toolPart.replace(/[_-]+/g, " ").trim();
|
|
935
1286
|
}
|