@tonyclaw/agent-inspector 3.1.19 → 3.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.output/backend/nitro.json +1 -1
  2. package/.output/cli.js +140 -20
  3. package/.output/server/_ssr/index.mjs +2 -2
  4. package/.output/server/_ssr/{router-0qlvqhVh.mjs → router-Be6BLuut.mjs} +2454 -1135
  5. package/.output/server/_tanstack-start-manifest_v-ClTaMkEj.mjs +4 -0
  6. package/.output/server/index.mjs +1 -1
  7. package/.output/ui/assets/{CompareDrawer-BOk9hd-Z.js → CompareDrawer-CGk2HFI1.js} +1 -1
  8. package/.output/ui/assets/{InspectorPet-DhHDBpk7.js → InspectorPet-CDM7SdOT.js} +1 -1
  9. package/.output/ui/assets/ProxyViewerContainer-CaWD78tw.js +60 -0
  10. package/.output/ui/assets/{ReplayDialog-DJhKtuNB.js → ReplayDialog-DqaeV1He.js} +1 -1
  11. package/.output/ui/assets/{RequestAnatomy-hP0tsqDC.js → RequestAnatomy-BtHl9P0k.js} +1 -1
  12. package/.output/ui/assets/{ResponseView-BSiUkwCb.js → ResponseView-C6t1vpxs.js} +1 -1
  13. package/.output/ui/assets/{StreamingChunkSequence-CzQqwv4X.js → StreamingChunkSequence-R4mgd3GQ.js} +1 -1
  14. package/.output/ui/assets/{_sessionId-BU-i6h0V.js → _sessionId-CL3hdLlQ.js} +1 -1
  15. package/.output/ui/assets/{_sessionId-C2Pq4Jb0.js → _sessionId-HS666S72.js} +1 -1
  16. package/.output/ui/assets/index-B-Qod-aA.css +1 -0
  17. package/.output/ui/assets/{index-D8C-t-2U.js → index-Bd9jPL4n.js} +1 -1
  18. package/.output/ui/assets/{index-zHpVF0jk.js → index-C4NHrclw.js} +1 -1
  19. package/.output/ui/assets/{index-UUnRwbOJ.js → index-UF1PJEcq.js} +1 -1
  20. package/.output/ui/assets/{index-B7NeIBDf.js → index-tX38BNV2.js} +2 -2
  21. package/.output/ui/assets/{json-viewer-DOzS5rUT.js → json-viewer-KSgzN_Ov.js} +1 -1
  22. package/.output/ui/assets/{jszip.min-CCCY7qNk.js → jszip.min-BiMDxghN.js} +1 -1
  23. package/.output/ui/index.html +2 -2
  24. package/.output/workers/logFinalizer.worker.js +261 -0
  25. package/.output/workers/sessionWorkerEntry.js +261 -0
  26. package/package.json +13 -6
  27. package/src/backend/routes/api/logs.$id.replay.ts +6 -1
  28. package/src/backend/routes/api/logs.stream.ts +7 -0
  29. package/src/backend/routes/api/providers.$providerId.model-metadata.ts +15 -4
  30. package/src/backend/routes/api/storage.ts +56 -0
  31. package/src/backend/routes/metrics.ts +14 -0
  32. package/src/cli/doctor.ts +135 -2
  33. package/src/cli.ts +7 -4
  34. package/src/components/ProxyViewerContainer.tsx +41 -24
  35. package/src/components/providers/SettingsDialog.tsx +261 -70
  36. package/src/components/proxy-viewer/LogEntry.tsx +19 -5
  37. package/src/components/proxy-viewer/bodyHydration.ts +67 -0
  38. package/src/components/proxy-viewer/proxyViewerContainerLogic.ts +78 -0
  39. package/src/contracts/index.ts +2 -0
  40. package/src/contracts/log.ts +5 -0
  41. package/src/knowledge/openclawClient.ts +11 -2
  42. package/src/knowledge/openclawGatewayClient.ts +6 -1
  43. package/src/lib/resourceLimits.ts +191 -0
  44. package/src/lib/safeFetch.ts +428 -20
  45. package/src/lib/stopReason.ts +66 -39
  46. package/src/proxy/ecosystemTasks.ts +20 -0
  47. package/src/proxy/handler.ts +17 -4
  48. package/src/proxy/identityProxy.ts +36 -16
  49. package/src/proxy/logIndex.ts +57 -6
  50. package/src/proxy/logger.ts +4 -0
  51. package/src/proxy/rawStreamCapture.ts +16 -1
  52. package/src/proxy/runtimeHealth.ts +4 -2
  53. package/src/proxy/runtimeMetrics.ts +149 -0
  54. package/src/proxy/schemas.ts +2 -0
  55. package/src/proxy/sessionRuntime.ts +38 -0
  56. package/src/proxy/sqliteLogIndex.ts +78 -2
  57. package/src/proxy/storageLifecycle.ts +432 -0
  58. package/src/proxy/store.ts +69 -13
  59. package/.output/server/_tanstack-start-manifest_v-DnbdNeun.mjs +0 -4
  60. package/.output/ui/assets/ProxyViewerContainer-BRYf987G.js +0 -59
  61. package/.output/ui/assets/index-IBEDJoV_.css +0 -1
@@ -150,28 +150,72 @@ export function SettingsDialog(): JSX.Element {
150
150
 
151
151
  type LogStorageStats = {
152
152
  memoryCount: number;
153
- logDir: string;
154
- logFileCount: number;
155
- logBytes: number;
156
- chunkDir: string;
157
- chunkFileCount: number;
158
- chunkBytes: number;
159
- sessionArchiveDir: string;
160
- sessionArchiveFileCount: number;
161
- sessionArchiveBytes: number;
153
+ inventory: {
154
+ checkedAt: string;
155
+ totalFiles: number;
156
+ totalBytes: number;
157
+ artifacts: StorageArtifactSummary[];
158
+ };
159
+ policy: {
160
+ maxAgeMs: number;
161
+ maxTotalBytes: number;
162
+ };
163
+ reclaimableBytes: number;
164
+ lastGc: {
165
+ checkedAt: string;
166
+ filesDeleted: number;
167
+ bytesDeleted: number;
168
+ errors: number;
169
+ beforeTotalBytes: number;
170
+ afterTotalBytes: number;
171
+ } | null;
162
172
  };
163
173
 
174
+ type StorageArtifactSummary = {
175
+ kind: string;
176
+ root: string;
177
+ sensitivity: string;
178
+ cleanupBehavior: string;
179
+ fileCount: number;
180
+ bytes: number;
181
+ oldestModifiedAt: string | null;
182
+ newestModifiedAt: string | null;
183
+ };
184
+
185
+ const StorageArtifactSummarySchema = z.object({
186
+ kind: z.string(),
187
+ root: z.string(),
188
+ sensitivity: z.string(),
189
+ cleanupBehavior: z.string(),
190
+ fileCount: z.number(),
191
+ bytes: z.number(),
192
+ oldestModifiedAt: z.string().nullable(),
193
+ newestModifiedAt: z.string().nullable(),
194
+ });
195
+
164
196
  const LogStorageStatsSchema = z.object({
165
197
  memoryCount: z.number(),
166
- logDir: z.string(),
167
- logFileCount: z.number(),
168
- logBytes: z.number(),
169
- chunkDir: z.string(),
170
- chunkFileCount: z.number(),
171
- chunkBytes: z.number(),
172
- sessionArchiveDir: z.string(),
173
- sessionArchiveFileCount: z.number(),
174
- sessionArchiveBytes: z.number(),
198
+ inventory: z.object({
199
+ checkedAt: z.string(),
200
+ totalFiles: z.number(),
201
+ totalBytes: z.number(),
202
+ artifacts: z.array(StorageArtifactSummarySchema),
203
+ }),
204
+ policy: z.object({
205
+ maxAgeMs: z.number(),
206
+ maxTotalBytes: z.number(),
207
+ }),
208
+ reclaimableBytes: z.number(),
209
+ lastGc: z
210
+ .object({
211
+ checkedAt: z.string(),
212
+ filesDeleted: z.number(),
213
+ bytesDeleted: z.number(),
214
+ errors: z.number(),
215
+ beforeTotalBytes: z.number(),
216
+ afterTotalBytes: z.number(),
217
+ })
218
+ .nullable(),
175
219
  });
176
220
 
177
221
  const STORAGE_STATS_FETCH_TIMEOUT_MS = 10_000;
@@ -183,10 +227,16 @@ function formatBytes(bytes: number): string {
183
227
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
184
228
  }
185
229
 
230
+ function formatRetention(maxAgeMs: number): string {
231
+ const days = Math.round(maxAgeMs / 1000 / 60 / 60 / 24);
232
+ return `${days.toLocaleString()} days`;
233
+ }
234
+
186
235
  function StorageSettingsTab(): JSX.Element {
187
236
  const [stats, setStats] = useState<LogStorageStats | null>(null);
188
237
  const [error, setError] = useState<string | null>(null);
189
238
  const [loading, setLoading] = useState(false);
239
+ const [gcLoading, setGcLoading] = useState(false);
190
240
  const [copiedId, setCopiedId] = useState<string | null>(null);
191
241
 
192
242
  const refresh = useCallback(async () => {
@@ -194,7 +244,7 @@ function StorageSettingsTab(): JSX.Element {
194
244
  setError(null);
195
245
  try {
196
246
  const data = await fetchJsonWithTimeout(
197
- "/api/logs?stats=1",
247
+ "/api/storage",
198
248
  LogStorageStatsSchema,
199
249
  STORAGE_STATS_FETCH_TIMEOUT_MS,
200
250
  undefined,
@@ -220,68 +270,98 @@ function StorageSettingsTab(): JSX.Element {
220
270
  });
221
271
  }, []);
222
272
 
273
+ const handleRunGc = useCallback(async () => {
274
+ setGcLoading(true);
275
+ setError(null);
276
+ try {
277
+ const data = await fetchJsonWithTimeout(
278
+ "/api/storage",
279
+ LogStorageStatsSchema,
280
+ STORAGE_STATS_FETCH_TIMEOUT_MS,
281
+ { method: "POST" },
282
+ (response) => `Failed to run storage GC: HTTP ${String(response.status)}`,
283
+ );
284
+ setStats(data);
285
+ } catch (err) {
286
+ setError(err instanceof Error ? err.message : String(err));
287
+ } finally {
288
+ setGcLoading(false);
289
+ }
290
+ }, []);
291
+
223
292
  return (
224
293
  <div className="space-y-4">
225
294
  <div className="flex items-center justify-between gap-3">
226
295
  <div className="min-w-0">
227
- <h3 className="text-sm font-semibold">Log storage</h3>
296
+ <h3 className="text-sm font-semibold">Storage lifecycle</h3>
228
297
  <p className="text-xs text-muted-foreground">
229
- Inspect local log and streaming chunk footprint before exporting or clearing sessions.
298
+ Inspect local captured-data footprint, retention policy, and safe reclaim actions.
230
299
  </p>
231
300
  </div>
232
- <Button type="button" variant="outline" size="sm" onClick={() => void refresh()}>
233
- {loading ? "Refreshing..." : "Refresh"}
234
- </Button>
301
+ <div className="flex shrink-0 items-center gap-2">
302
+ <Button
303
+ type="button"
304
+ variant="outline"
305
+ size="sm"
306
+ onClick={() => void handleRunGc()}
307
+ disabled={gcLoading}
308
+ >
309
+ {gcLoading ? "Running..." : "Run GC now"}
310
+ </Button>
311
+ <Button type="button" variant="outline" size="sm" onClick={() => void refresh()}>
312
+ {loading ? "Refreshing..." : "Refresh"}
313
+ </Button>
314
+ </div>
235
315
  </div>
236
316
 
237
317
  {stats !== null && (
238
318
  <div className="grid gap-2 text-xs">
239
- <CopyableSetupValue
240
- id="log-dir"
241
- label="Log storage path"
242
- value={stats.logDir}
243
- copiedId={copiedId}
244
- onCopy={handleCopy}
245
- />
246
- <CopyableSetupValue
247
- id="chunk-dir"
248
- label="Streaming chunks storage path"
249
- value={stats.chunkDir}
250
- copiedId={copiedId}
251
- onCopy={handleCopy}
252
- />
253
- <CopyableSetupValue
254
- id="session-archive-dir"
255
- label="Session archive storage path"
256
- value={stats.sessionArchiveDir}
257
- copiedId={copiedId}
258
- onCopy={handleCopy}
259
- />
260
- <div className="rounded-md bg-muted/20 px-3 py-2">
261
- <div className="font-medium">In-memory logs</div>
262
- <div className="mt-1 font-mono text-muted-foreground">
263
- {stats.memoryCount.toLocaleString()} loaded
264
- </div>
265
- </div>
266
- <div className="rounded-md bg-muted/20 px-3 py-2">
267
- <div className="font-medium">Log files</div>
268
- <div className="mt-1 font-mono text-muted-foreground">
269
- {stats.logFileCount.toLocaleString()} files / {formatBytes(stats.logBytes)}
270
- </div>
271
- </div>
272
- <div className="rounded-md bg-muted/20 px-3 py-2">
273
- <div className="font-medium">Streaming chunks</div>
274
- <div className="mt-1 font-mono text-muted-foreground">
275
- {stats.chunkFileCount.toLocaleString()} files / {formatBytes(stats.chunkBytes)}
276
- </div>
277
- </div>
278
- <div className="rounded-md bg-muted/20 px-3 py-2">
279
- <div className="font-medium">Session archives</div>
280
- <div className="mt-1 font-mono text-muted-foreground">
281
- {stats.sessionArchiveFileCount.toLocaleString()} files /{" "}
282
- {formatBytes(stats.sessionArchiveBytes)}
283
- </div>
319
+ <div className="grid gap-2 sm:grid-cols-2">
320
+ <StorageMetricCard
321
+ label="Persisted footprint"
322
+ value={`${stats.inventory.totalFiles.toLocaleString()} files / ${formatBytes(
323
+ stats.inventory.totalBytes,
324
+ )}`}
325
+ detail={`Checked ${new Date(stats.inventory.checkedAt).toLocaleString()}`}
326
+ />
327
+ <StorageMetricCard
328
+ label="Auto-reclaimable"
329
+ value={formatBytes(stats.reclaimableBytes)}
330
+ detail={`Retention ${formatRetention(stats.policy.maxAgeMs)} / max ${formatBytes(
331
+ stats.policy.maxTotalBytes,
332
+ )}`}
333
+ />
334
+ <StorageMetricCard
335
+ label="In-memory logs"
336
+ value={`${stats.memoryCount.toLocaleString()} loaded`}
337
+ detail="Recent completed entries kept hot for the UI"
338
+ />
339
+ <StorageMetricCard
340
+ label="Last GC"
341
+ value={
342
+ stats.lastGc === null
343
+ ? "Not run yet"
344
+ : `${stats.lastGc.filesDeleted.toLocaleString()} files / ${formatBytes(
345
+ stats.lastGc.bytesDeleted,
346
+ )}`
347
+ }
348
+ detail={
349
+ stats.lastGc === null
350
+ ? "Runs at startup, on schedule, or manually here"
351
+ : `${new Date(stats.lastGc.checkedAt).toLocaleString()} · ${
352
+ stats.lastGc.errors
353
+ } errors`
354
+ }
355
+ />
284
356
  </div>
357
+ {stats.inventory.artifacts.map((artifact) => (
358
+ <StorageArtifactRow
359
+ key={`${artifact.kind}:${artifact.root}`}
360
+ artifact={artifact}
361
+ copiedId={copiedId}
362
+ onCopy={handleCopy}
363
+ />
364
+ ))}
285
365
  </div>
286
366
  )}
287
367
 
@@ -290,6 +370,81 @@ function StorageSettingsTab(): JSX.Element {
290
370
  );
291
371
  }
292
372
 
373
+ function cleanupBehaviorLabel(cleanupBehavior: string): string {
374
+ switch (cleanupBehavior) {
375
+ case "retention-gc":
376
+ return "Retention GC";
377
+ case "orphan-reclaim":
378
+ return "Orphan reclaim";
379
+ case "rotation":
380
+ return "Rotation-owned";
381
+ case "manual-owner-delete":
382
+ return "Manual only";
383
+ default:
384
+ return cleanupBehavior;
385
+ }
386
+ }
387
+
388
+ function StorageMetricCard({
389
+ label,
390
+ value,
391
+ detail,
392
+ }: {
393
+ label: string;
394
+ value: string;
395
+ detail: string;
396
+ }): JSX.Element {
397
+ return (
398
+ <div className="rounded-md bg-muted/20 px-3 py-2">
399
+ <div className="font-medium">{label}</div>
400
+ <div className="mt-1 font-mono text-muted-foreground">{value}</div>
401
+ <div className="mt-1 text-[11px] text-muted-foreground">{detail}</div>
402
+ </div>
403
+ );
404
+ }
405
+
406
+ function StorageArtifactRow({
407
+ artifact,
408
+ copiedId,
409
+ onCopy,
410
+ }: {
411
+ artifact: StorageArtifactSummary;
412
+ copiedId: string | null;
413
+ onCopy: (id: string, value: string) => void;
414
+ }): JSX.Element {
415
+ const id = `storage-${artifact.kind}`;
416
+ const oldest = artifact.oldestModifiedAt === null ? "none" : new Date(artifact.oldestModifiedAt);
417
+ const newest = artifact.newestModifiedAt === null ? "none" : new Date(artifact.newestModifiedAt);
418
+ return (
419
+ <div className="rounded-md border border-border/45 bg-muted/15 px-3 py-2">
420
+ <div className="flex flex-wrap items-center justify-between gap-2">
421
+ <div>
422
+ <div className="font-medium">{artifact.kind}</div>
423
+ <div className="mt-1 text-[11px] text-muted-foreground">
424
+ {cleanupBehaviorLabel(artifact.cleanupBehavior)} · {artifact.sensitivity}
425
+ </div>
426
+ </div>
427
+ <div className="text-right font-mono text-muted-foreground">
428
+ {artifact.fileCount.toLocaleString()} files / {formatBytes(artifact.bytes)}
429
+ </div>
430
+ </div>
431
+ <div className="mt-2 grid gap-1 text-[11px] text-muted-foreground sm:grid-cols-2">
432
+ <div>Oldest: {oldest instanceof Date ? oldest.toLocaleString() : oldest}</div>
433
+ <div>Newest: {newest instanceof Date ? newest.toLocaleString() : newest}</div>
434
+ </div>
435
+ <div className="mt-2">
436
+ <CopyableSetupValue
437
+ id={id}
438
+ label="Root path"
439
+ value={artifact.root}
440
+ copiedId={copiedId}
441
+ onCopy={onCopy}
442
+ />
443
+ </div>
444
+ </div>
445
+ );
446
+ }
447
+
293
448
  function CopyableSetupValue({
294
449
  id,
295
450
  label,
@@ -330,7 +485,7 @@ function CopyableSetupValue({
330
485
  );
331
486
  }
332
487
 
333
- function OnboardingSettingsTab(): JSX.Element {
488
+ export function OnboardingSettingsTab(): JSX.Element {
334
489
  const [copiedId, setCopiedId] = useState<string | null>(null);
335
490
  const [endpoints, setEndpoints] = useState<CodingAgentEndpoints | null>(null);
336
491
  useEffect(() => subscribeCodingAgentEndpoints(setEndpoints), []);
@@ -349,6 +504,12 @@ function OnboardingSettingsTab(): JSX.Element {
349
504
  value: "agent-inspector onboard --uninstall",
350
505
  },
351
506
  { id: "claude", label: "Claude Code command", value: "/agent-inspector:onboard" },
507
+ { id: "doctor", label: "Backend doctor", value: "agent-inspector doctor --verbose" },
508
+ {
509
+ id: "support-bundle",
510
+ label: "Redacted support bundle",
511
+ value: "agent-inspector doctor --support-bundle",
512
+ },
352
513
  {
353
514
  id: "codex",
354
515
  label: "Codex prompt",
@@ -432,6 +593,18 @@ function OnboardingSettingsTab(): JSX.Element {
432
593
  />
433
594
  ))}
434
595
  </div>
596
+ <div className="grid gap-2 rounded-md border border-status-info/25 bg-status-info/10 px-3 py-2 text-xs">
597
+ <div className="font-medium text-foreground">Verified first trace flow</div>
598
+ <ol className="ml-4 list-decimal space-y-1 text-muted-foreground">
599
+ <li>Start Agent Inspector and confirm the application bar shows Live.</li>
600
+ <li>Copy the Proxy URL or MCP URL that matches your coding agent.</li>
601
+ <li>Run a tiny coding-agent prompt such as “say hello and stop”.</li>
602
+ <li>
603
+ Return to Logs and confirm one new request appears with request, response, tokens, and
604
+ tool activity when present.
605
+ </li>
606
+ </ol>
607
+ </div>
435
608
  <div className="grid gap-2 rounded-md bg-muted/15 px-3 py-2 text-xs text-muted-foreground">
436
609
  <div className="flex items-center gap-2">
437
610
  <Check className="size-3.5 text-status-success" />
@@ -494,6 +667,24 @@ function OnboardingSettingsTab(): JSX.Element {
494
667
  reachable from that tool and verify the network/firewall path.
495
668
  </span>
496
669
  </div>
670
+ <div className="flex items-start gap-2">
671
+ <Check className="mt-0.5 size-3.5 text-status-success" />
672
+ <span>
673
+ If the first trace does not appear, run{" "}
674
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">
675
+ agent-inspector doctor --verbose
676
+ </code>{" "}
677
+ and compare the agent base URL with the current Proxy URL. A base-path mismatch such as{" "}
678
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">
679
+ /
680
+ </code>{" "}
681
+ versus{" "}
682
+ <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">
683
+ /inspector
684
+ </code>{" "}
685
+ means the tool is talking to a different Inspector surface.
686
+ </span>
687
+ </div>
497
688
  </div>
498
689
  </div>
499
690
  );
@@ -29,9 +29,12 @@ import {
29
29
  import type { AnatomySegment } from "./anatomy/types";
30
30
  import { useAnatomyJump } from "./anatomy/useAnatomyJump";
31
31
  import {
32
+ deleteHydratedLogFromCache,
32
33
  describeFullBodyHydrationError,
33
34
  formatBodyHydrationFootprint,
34
35
  formatBytes,
36
+ getOrFetchHydratedLog,
37
+ readHydratedLogFromCache,
35
38
  type FullBodyHydrationError,
36
39
  } from "./bodyHydration";
37
40
  import { computeHeadersDiff, computeRequestDiff, DiffView } from "./diff";
@@ -874,11 +877,12 @@ export const LogEntry = memo(function ({
874
877
 
875
878
  const retryFullBodyHydration = useCallback(() => {
876
879
  if (bodyHydrationInFlightRef.current) return;
880
+ deleteHydratedLogFromCache(log.id);
877
881
  setHydratedLog(null);
878
882
  setBodyHydrationStatus("idle");
879
883
  setBodyHydrationError(null);
880
884
  setBodyHydrationRetryNonce((current) => current + 1);
881
- }, []);
885
+ }, [log.id]);
882
886
 
883
887
  const handleToggleExpanded = useCallback(() => {
884
888
  const nextExpanded = !expanded;
@@ -931,14 +935,24 @@ export const LogEntry = memo(function ({
931
935
  if (!shouldHydrateLogBody(log)) return;
932
936
  if (hydratedLog !== null || bodyHydrationInFlightRef.current) return;
933
937
 
938
+ const cached = readHydratedLogFromCache(log.id);
939
+ if (cached !== null) {
940
+ setHydratedLog(cached);
941
+ setBodyHydrationStatus("idle");
942
+ setBodyHydrationError(null);
943
+ return;
944
+ }
945
+
934
946
  let cancelled = false;
935
947
  bodyHydrationInFlightRef.current = true;
936
948
  setBodyHydrationStatus("loading");
937
949
  setBodyHydrationError(null);
938
- void fetchJsonWithTimeout(
939
- `/api/logs/${String(log.id)}`,
940
- CapturedLogSchema,
941
- FULL_LOG_HYDRATION_TIMEOUT_MS,
950
+ void getOrFetchHydratedLog(log.id, () =>
951
+ fetchJsonWithTimeout(
952
+ `/api/logs/${String(log.id)}`,
953
+ CapturedLogSchema,
954
+ FULL_LOG_HYDRATION_TIMEOUT_MS,
955
+ ),
942
956
  )
943
957
  .then((fullLog) => {
944
958
  if (cancelled) return;
@@ -1,4 +1,5 @@
1
1
  import { ApiTimeoutError } from "../../lib/apiClient";
2
+ import type { CapturedLog } from "../../contracts";
2
3
 
3
4
  export type FullBodyHydrationError = {
4
5
  title: string;
@@ -7,6 +8,10 @@ export type FullBodyHydrationError = {
7
8
 
8
9
  const DEFAULT_FULL_BODY_HYDRATION_ERROR_DETAIL =
9
10
  "The compact log metadata is still available. Retry if the local server was restarting or the body store was briefly unavailable.";
11
+ const DEFAULT_DETAIL_CACHE_LIMIT = 64;
12
+
13
+ const hydratedLogCache = new Map<number, CapturedLog>();
14
+ const hydratedLogInFlight = new Map<number, Promise<CapturedLog>>();
10
15
 
11
16
  export function formatBytes(bytes: number): string {
12
17
  if (bytes < 1024) return `${bytes.toLocaleString()} B`;
@@ -54,3 +59,65 @@ export function describeFullBodyHydrationError(error: unknown): FullBodyHydratio
54
59
  detail: DEFAULT_FULL_BODY_HYDRATION_ERROR_DETAIL,
55
60
  };
56
61
  }
62
+
63
+ function detailCacheLimit(): number {
64
+ const raw = process.env["AGENT_INSPECTOR_UI_DETAIL_CACHE_LIMIT"];
65
+ if (raw === undefined || raw === "") return DEFAULT_DETAIL_CACHE_LIMIT;
66
+ const parsed = Number(raw);
67
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1000) {
68
+ return DEFAULT_DETAIL_CACHE_LIMIT;
69
+ }
70
+ return parsed;
71
+ }
72
+
73
+ function rememberHydratedLog(log: CapturedLog): CapturedLog {
74
+ hydratedLogCache.delete(log.id);
75
+ hydratedLogCache.set(log.id, log);
76
+ while (hydratedLogCache.size > detailCacheLimit()) {
77
+ const oldest = hydratedLogCache.keys().next().value;
78
+ if (typeof oldest !== "number") break;
79
+ hydratedLogCache.delete(oldest);
80
+ }
81
+ return log;
82
+ }
83
+
84
+ export function readHydratedLogFromCache(logId: number): CapturedLog | null {
85
+ const cached = hydratedLogCache.get(logId);
86
+ if (cached === undefined) return null;
87
+ hydratedLogCache.delete(logId);
88
+ hydratedLogCache.set(logId, cached);
89
+ return cached;
90
+ }
91
+
92
+ export function deleteHydratedLogFromCache(logId: number): void {
93
+ hydratedLogCache.delete(logId);
94
+ hydratedLogInFlight.delete(logId);
95
+ }
96
+
97
+ export function clearHydratedLogCacheForTests(): void {
98
+ hydratedLogCache.clear();
99
+ hydratedLogInFlight.clear();
100
+ }
101
+
102
+ export function getHydratedLogCacheSizeForTests(): number {
103
+ return hydratedLogCache.size;
104
+ }
105
+
106
+ export async function getOrFetchHydratedLog(
107
+ logId: number,
108
+ fetcher: () => Promise<CapturedLog>,
109
+ ): Promise<CapturedLog> {
110
+ const cached = readHydratedLogFromCache(logId);
111
+ if (cached !== null) return cached;
112
+
113
+ const existing = hydratedLogInFlight.get(logId);
114
+ if (existing !== undefined) return await existing;
115
+
116
+ const pending = fetcher()
117
+ .then(rememberHydratedLog)
118
+ .finally(() => {
119
+ hydratedLogInFlight.delete(logId);
120
+ });
121
+ hydratedLogInFlight.set(logId, pending);
122
+ return await pending;
123
+ }
@@ -31,6 +31,84 @@ export function mergeLogsById(
31
31
  return [...byId.values()].toSorted((left, right) => left.id - right.id);
32
32
  }
33
33
 
34
+ export type IncrementalLogMergeOptions = {
35
+ recentLimit: number;
36
+ retainSessionId?: string;
37
+ retainIds?: ReadonlySet<number>;
38
+ };
39
+
40
+ export type IncrementalLogMergeResult = {
41
+ logs: CapturedLog[];
42
+ index: Map<number, number>;
43
+ };
44
+
45
+ function shouldRetainLog(log: CapturedLog, options: IncrementalLogMergeOptions): boolean {
46
+ if (options.retainIds?.has(log.id) === true) return true;
47
+ if (options.retainSessionId !== undefined && log.sessionId === options.retainSessionId) {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function trimBoundedLogs(logs: CapturedLog[], options: IncrementalLogMergeOptions): CapturedLog[] {
54
+ const unretainedCount = logs.reduce(
55
+ (count, log) => count + (shouldRetainLog(log, options) ? 0 : 1),
56
+ 0,
57
+ );
58
+ if (unretainedCount <= options.recentLimit) return logs;
59
+
60
+ let unretainedToDrop = unretainedCount - options.recentLimit;
61
+ const retained: CapturedLog[] = [];
62
+ for (const log of logs) {
63
+ if (unretainedToDrop > 0 && !shouldRetainLog(log, options)) {
64
+ unretainedToDrop -= 1;
65
+ continue;
66
+ }
67
+ retained.push(log);
68
+ }
69
+ return retained;
70
+ }
71
+
72
+ export function buildLogIndexById(logs: readonly CapturedLog[]): Map<number, number> {
73
+ const index = new Map<number, number>();
74
+ for (let i = 0; i < logs.length; i += 1) {
75
+ const log = logs[i];
76
+ if (log !== undefined) index.set(log.id, i);
77
+ }
78
+ return index;
79
+ }
80
+
81
+ export function mergeLogsIncrementally(
82
+ previousLogs: readonly CapturedLog[],
83
+ previousIndex: ReadonlyMap<number, number>,
84
+ incomingLogs: readonly CapturedLog[],
85
+ options: IncrementalLogMergeOptions,
86
+ ): IncrementalLogMergeResult {
87
+ if (incomingLogs.length === 0) {
88
+ const logs = trimBoundedLogs([...previousLogs], options);
89
+ return { logs, index: buildLogIndexById(logs) };
90
+ }
91
+
92
+ const next = [...previousLogs];
93
+ let lastId = next.length === 0 ? Number.NEGATIVE_INFINITY : (next.at(-1)?.id ?? 0);
94
+ let sorted = true;
95
+
96
+ for (const log of incomingLogs) {
97
+ const existingIndex = previousIndex.get(log.id);
98
+ if (existingIndex !== undefined && existingIndex >= 0 && existingIndex < next.length) {
99
+ next[existingIndex] = log;
100
+ continue;
101
+ }
102
+ next.push(log);
103
+ if (log.id < lastId) sorted = false;
104
+ lastId = Math.max(lastId, log.id);
105
+ }
106
+
107
+ const sortedLogs = sorted ? next : next.toSorted((left, right) => left.id - right.id);
108
+ const logs = trimBoundedLogs(sortedLogs, options);
109
+ return { logs, index: buildLogIndexById(logs) };
110
+ }
111
+
34
112
  export function mergeSessionIds(
35
113
  previousSessions: readonly string[],
36
114
  incomingSessions: readonly string[],
@@ -9,6 +9,7 @@ export {
9
9
  LogBodyPartSchema,
10
10
  parseLogId,
11
11
  StreamingChunkSchema,
12
+ StopReasonSchema,
12
13
  ToolTraceEventSchema,
13
14
  } from "./log";
14
15
  export type {
@@ -19,6 +20,7 @@ export type {
19
20
  LogId,
20
21
  LogBodyPart,
21
22
  StreamingChunk,
23
+ StopReason,
22
24
  ToolTraceEvent,
23
25
  TokenUsage,
24
26
  } from "./log";
@@ -54,6 +54,10 @@ export const ToolTraceEventSchema = z.object({
54
54
 
55
55
  export type ToolTraceEvent = z.infer<typeof ToolTraceEventSchema>;
56
56
 
57
+ const StopReasonValueSchema = z.enum(["end_turn", "tool_use", "stop", "length"]);
58
+ export const StopReasonSchema = StopReasonValueSchema.nullable();
59
+ export type StopReason = z.infer<typeof StopReasonSchema>;
60
+
57
61
  const StreamingChunksArraySchema = z.object({
58
62
  chunks: z.array(StreamingChunkSchema),
59
63
  truncated: z.boolean().optional().default(false),
@@ -120,6 +124,7 @@ export const CapturedLogSchema = z.object({
120
124
  captureIncompleteReason: CaptureIncompleteReasonSchema.nullable().optional(),
121
125
  warnings: z.array(z.string()).optional(),
122
126
  toolTraceEvents: z.array(ToolTraceEventSchema).optional(),
127
+ stopReason: StopReasonSchema.optional(),
123
128
  /** Error message from streaming response (e.g., SSE error event) */
124
129
  error: z.string().nullable().optional(),
125
130
  });