@tonyclaw/agent-inspector 3.0.7 → 3.0.9

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 (73) hide show
  1. package/.output/cli.js +245 -171
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-C0Kbsm-Q.js → CompareDrawer-CJKL4LMm.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-DBUy29O-.js +106 -0
  5. package/.output/public/assets/ReplayDialog-BYanl-a_.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-fa0JXy5p.js → RequestAnatomy-DvPz1jqE.js} +1 -1
  7. package/.output/public/assets/ResponseView-sZnxp9GN.js +3 -0
  8. package/.output/public/assets/StreamingChunkSequence-G-tiYiBd.js +1 -0
  9. package/.output/public/assets/{_sessionId-D5fYH2C0.js → _sessionId-_hDcsmXq.js} +1 -1
  10. package/.output/public/assets/agent-inspector-58K1_MsC.ico +0 -0
  11. package/.output/public/assets/index-DAPAWsAd.js +1 -0
  12. package/.output/public/assets/index-DspnLqUW.css +1 -0
  13. package/.output/public/assets/{index-BEsqvywM.js → index-SuOSQb2_.js} +1 -1
  14. package/.output/public/assets/{json-viewer-B0QRbMSf.js → json-viewer-yQdhgjaw.js} +1 -1
  15. package/.output/public/assets/{main-CuKZD7n0.js → main-CgjcDxjR.js} +2 -2
  16. package/.output/server/_libs/radix-ui__react-dialog.mjs +2 -2
  17. package/.output/server/{_sessionId-DdBTK3qm.mjs → _sessionId-BGISf2En.mjs} +3 -3
  18. package/.output/server/_ssr/{CompareDrawer-m82cVZ73.mjs → CompareDrawer-s-UF9gW3.mjs} +2 -2
  19. package/.output/server/_ssr/{ProxyViewerContainer-7geuf78f.mjs → ProxyViewerContainer-C0Sbz9ED.mjs} +1033 -530
  20. package/.output/server/_ssr/{ReplayDialog-B8RlWLLH.mjs → ReplayDialog-DvS8gvJr.mjs} +58 -23
  21. package/.output/server/_ssr/{RequestAnatomy-BE7pmwpv.mjs → RequestAnatomy-BwhWglJL.mjs} +2 -2
  22. package/.output/server/_ssr/{ResponseView-D-sDsdQr.mjs → ResponseView-wBVreqQf.mjs} +2 -2
  23. package/.output/server/_ssr/{StreamingChunkSequence-DU_hThJr.mjs → StreamingChunkSequence-Dr6eg-MJ.mjs} +30 -20
  24. package/.output/server/_ssr/{index-D0sV8UbE.mjs → index-B98Wia1X.mjs} +2 -2
  25. package/.output/server/_ssr/index.mjs +2 -2
  26. package/.output/server/_ssr/{json-viewer-CaDh1zrw.mjs → json-viewer-D7L8y0FH.mjs} +2 -2
  27. package/.output/server/_ssr/{router-Zh2iZ_u0.mjs → router-g9mo0nWT.mjs} +459 -196
  28. package/.output/server/_tanstack-start-manifest_v-CbGc-o50.mjs +4 -0
  29. package/.output/server/index.mjs +76 -69
  30. package/package.json +1 -1
  31. package/src/assets/IDE/opencode.svg +1 -1
  32. package/src/assets/IDE/xiaomimimo.svg +1 -1
  33. package/src/cli/detect-tools.ts +2 -27
  34. package/src/cli/templates/codex-skill-onboard.ts +1 -1
  35. package/src/cli/templates/skill-onboard.ts +2 -3
  36. package/src/cli.ts +9 -86
  37. package/src/components/ProxyViewer.tsx +249 -37
  38. package/src/components/ProxyViewerContainer.tsx +167 -22
  39. package/src/components/clients/ClientLogo.tsx +38 -3
  40. package/src/components/ecosystem/AgentLabDialog.tsx +204 -3
  41. package/src/components/groups/GroupsDialog.tsx +28 -40
  42. package/src/components/providers/ImportWizardDialog.tsx +51 -101
  43. package/src/components/providers/ProvidersPanel.tsx +82 -68
  44. package/src/components/providers/SettingsDialog.tsx +11 -12
  45. package/src/components/proxy-viewer/AgentTraceSummary.tsx +15 -23
  46. package/src/components/proxy-viewer/ReplayDialog.tsx +65 -22
  47. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +33 -20
  48. package/src/components/proxy-viewer/TurnGroup.tsx +0 -16
  49. package/src/components/proxy-viewer/log-formats/anthropic.ts +5 -5
  50. package/src/components/proxy-viewer/log-formats/openai.ts +7 -10
  51. package/src/components/proxy-viewer/requestTools.ts +5 -2
  52. package/src/lib/apiClient.ts +43 -10
  53. package/src/lib/ecosystemContract.ts +32 -0
  54. package/src/lib/export-logs.ts +37 -3
  55. package/src/proxy/ecosystemRegistry.ts +244 -0
  56. package/src/proxy/ecosystemTasks.ts +153 -0
  57. package/src/proxy/platformCommands.ts +212 -0
  58. package/src/proxy/socketTracker.ts +84 -143
  59. package/src/routes/api/ecosystem.packages.$packageId.help.ts +16 -0
  60. package/src/routes/api/ecosystem.packages.$packageId.install.ts +16 -0
  61. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +19 -0
  62. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +16 -0
  63. package/src/routes/api/ecosystem.packages.ts +1 -248
  64. package/src/routes/api/ecosystem.tasks.$taskId.ts +16 -0
  65. package/src/routes/api/ecosystem.tasks.ts +12 -0
  66. package/src/routes/api/providers.$providerId.test.log.ts +28 -24
  67. package/.output/public/assets/ProxyViewerContainer-p-rpYyiF.js +0 -106
  68. package/.output/public/assets/ReplayDialog-CM3dhP8v.js +0 -1
  69. package/.output/public/assets/ResponseView-Bq6sC7Ay.js +0 -3
  70. package/.output/public/assets/StreamingChunkSequence-CGOun3Df.js +0 -1
  71. package/.output/public/assets/index-C29Olfzt.js +0 -1
  72. package/.output/public/assets/index-DgANE0r3.css +0 -1
  73. package/.output/server/_tanstack-start-manifest_v-LCE-2AaF.mjs +0 -4
@@ -7,7 +7,13 @@ import { ImportWizardDialog } from "./ImportWizardDialog";
7
7
  import { ConfirmDialog } from "../ui/confirm-dialog";
8
8
  import { ProviderCard } from "./ProviderCard";
9
9
  import { ProviderForm } from "./ProviderForm";
10
- import { parseJsonResponse, readApiError } from "../../lib/apiClient";
10
+ import {
11
+ ApiTimeoutError,
12
+ fetchJsonWithTimeout,
13
+ fetchWithTimeout,
14
+ parseJsonResponse,
15
+ readApiError,
16
+ } from "../../lib/apiClient";
11
17
  import { copyTextToClipboard } from "../../lib/clipboard";
12
18
  import {
13
19
  ProviderConfigSchema,
@@ -44,6 +50,13 @@ const ModelMetadataRefreshResponseSchema = z.object({
44
50
  });
45
51
 
46
52
  const NETWORK_ERROR_MESSAGE = "Network error: could not reach the server. Is the proxy running?";
53
+ const CONFIG_PATH_FETCH_TIMEOUT_MS = 5_000;
54
+ const PROVIDER_TEST_LOG_TIMEOUT_MS = 5_000;
55
+ const PROVIDER_WRITE_TIMEOUT_MS = 15_000;
56
+ const PROVIDER_DELETE_TIMEOUT_MS = 10_000;
57
+ const PROVIDER_EXPORT_TIMEOUT_MS = 20_000;
58
+ const PROVIDER_IMPORT_TIMEOUT_MS = 20_000;
59
+ const PROVIDER_METADATA_TIMEOUT_MS = 30_000;
47
60
 
48
61
  type ProviderFormData = {
49
62
  name: string;
@@ -88,7 +101,7 @@ async function persistProviderTestLog(providerId: string, results: TestResults):
88
101
  return;
89
102
  }
90
103
  try {
91
- await fetch(`/api/providers/${providerId}/test/log`, {
104
+ await fetchWithTimeout(`/api/providers/${providerId}/test/log`, PROVIDER_TEST_LOG_TIMEOUT_MS, {
92
105
  method: "POST",
93
106
  headers: { "Content-Type": "application/json" },
94
107
  body: JSON.stringify(results),
@@ -176,11 +189,12 @@ export function ProvidersPanel({
176
189
  useEffect(() => {
177
190
  void (async () => {
178
191
  try {
179
- const res = await fetch("/api/config/paths");
180
- if (res.ok) {
181
- const data = await parseJsonResponse(res, ConfigPathsResponseSchema);
182
- setConfigPath(data.providerConfig);
183
- }
192
+ const data = await fetchJsonWithTimeout(
193
+ "/api/config/paths",
194
+ ConfigPathsResponseSchema,
195
+ CONFIG_PATH_FETCH_TIMEOUT_MS,
196
+ );
197
+ setConfigPath(data.providerConfig);
184
198
  } catch {
185
199
  // Ignore
186
200
  }
@@ -270,34 +284,33 @@ export function ProvidersPanel({
270
284
  }, 1000);
271
285
 
272
286
  try {
273
- const res = await fetch(`/api/providers/${providerId}/test`, {
274
- method: "POST",
275
- signal: controller.signal,
276
- });
277
- if (res.ok) {
278
- const results = await parseJsonResponse(res, ProviderTestResultsSchema);
279
- updateTestResults(providerId, results);
280
- await persistProviderTestLog(providerId, results);
281
- } else {
282
- updateTestResults(
283
- providerId,
284
- createFailedProviderTestResults(
285
- `HTTP ${res.status}: ${res.statusText}`,
286
- "server_error",
287
- ),
288
- );
289
- }
287
+ const results = await fetchJsonWithTimeout(
288
+ `/api/providers/${providerId}/test`,
289
+ ProviderTestResultsSchema,
290
+ timeoutSeconds * 1000 + 1000,
291
+ {
292
+ method: "POST",
293
+ signal: controller.signal,
294
+ },
295
+ (response) => `HTTP ${response.status}: ${response.statusText}`,
296
+ );
297
+ updateTestResults(providerId, results);
298
+ await persistProviderTestLog(providerId, results);
290
299
  } catch (err) {
291
300
  // Check if this was an abort (timeout)
292
301
  const isAbort = err instanceof Error && err.name === "AbortError";
293
- if (isAbort) {
302
+ const isTimeout = err instanceof ApiTimeoutError;
303
+ if (isAbort || isTimeout) {
294
304
  updateTestResults(
295
305
  providerId,
296
306
  createFailedProviderTestResults("Request timed out", "timeout"),
297
307
  );
308
+ } else if (err instanceof Error) {
309
+ updateTestResults(
310
+ providerId,
311
+ createFailedProviderTestResults(err.message, "server_error"),
312
+ );
298
313
  }
299
- // If it's not an abort error, the test results won't be updated
300
- // which means the previous results will persist (or it will show "Not configured" reset state)
301
314
  } finally {
302
315
  // Always clear the countdown and testing state, even if an error occurs
303
316
  clearInterval(intervalId);
@@ -323,18 +336,17 @@ export function ProvidersPanel({
323
336
  });
324
337
  try {
325
338
  const payload = url !== undefined && url.trim() !== "" ? { url } : {};
326
- const res = await fetch(`/api/providers/${providerId}/model-metadata`, {
327
- method: "POST",
328
- headers: { "Content-Type": "application/json" },
329
- body: JSON.stringify(payload),
330
- });
331
- if (!res.ok) {
332
- if (showError) {
333
- setError(await readApiError(res, "Failed to refresh model metadata"));
334
- }
335
- return false;
336
- }
337
- const data = await parseJsonResponse(res, ModelMetadataRefreshResponseSchema);
339
+ const data = await fetchJsonWithTimeout(
340
+ `/api/providers/${providerId}/model-metadata`,
341
+ ModelMetadataRefreshResponseSchema,
342
+ PROVIDER_METADATA_TIMEOUT_MS,
343
+ {
344
+ method: "POST",
345
+ headers: { "Content-Type": "application/json" },
346
+ body: JSON.stringify(payload),
347
+ },
348
+ () => "Failed to refresh model metadata",
349
+ );
338
350
  if (data.imported === 0 && showError) {
339
351
  setError("No matching model metadata found for this provider.");
340
352
  } else {
@@ -343,9 +355,9 @@ export function ProvidersPanel({
343
355
  triggerHighlight(data.provider.id);
344
356
  refreshProviders();
345
357
  return data.imported > 0;
346
- } catch {
358
+ } catch (err) {
347
359
  if (showError) {
348
- setError(NETWORK_ERROR_MESSAGE);
360
+ setError(err instanceof Error ? err.message : NETWORK_ERROR_MESSAGE);
349
361
  }
350
362
  return false;
351
363
  } finally {
@@ -362,16 +374,17 @@ export function ProvidersPanel({
362
374
  function handleAddProvider(data: ProviderFormData): void {
363
375
  void (async () => {
364
376
  try {
365
- const res = await fetch("/api/providers", {
366
- method: "POST",
367
- headers: { "Content-Type": "application/json" },
368
- body: JSON.stringify(createProviderPayload(data)),
369
- });
370
- if (!res.ok) {
371
- setError(await readApiError(res, "Failed to add provider"));
372
- return;
373
- }
374
- const newProvider = await parseJsonResponse(res, ProviderConfigSchema);
377
+ const newProvider = await fetchJsonWithTimeout(
378
+ "/api/providers",
379
+ ProviderConfigSchema,
380
+ PROVIDER_WRITE_TIMEOUT_MS,
381
+ {
382
+ method: "POST",
383
+ headers: { "Content-Type": "application/json" },
384
+ body: JSON.stringify(createProviderPayload(data)),
385
+ },
386
+ () => "Failed to add provider",
387
+ );
375
388
  setShowForm(false);
376
389
  triggerHighlight(newProvider.id);
377
390
  if (data.modelMetadataUrl !== undefined && data.modelMetadataUrl.trim() !== "") {
@@ -379,8 +392,8 @@ export function ProvidersPanel({
379
392
  }
380
393
  refreshProviders();
381
394
  await runTest(newProvider.id);
382
- } catch {
383
- setError(NETWORK_ERROR_MESSAGE);
395
+ } catch (err) {
396
+ setError(err instanceof Error ? err.message : NETWORK_ERROR_MESSAGE);
384
397
  }
385
398
  })();
386
399
  }
@@ -389,16 +402,17 @@ export function ProvidersPanel({
389
402
  if (!editingProvider) return;
390
403
  void (async () => {
391
404
  try {
392
- const res = await fetch(`/api/providers/${editingProvider.id}`, {
393
- method: "PUT",
394
- headers: { "Content-Type": "application/json" },
395
- body: JSON.stringify(createProviderPayload(data)),
396
- });
397
- if (!res.ok) {
398
- setError(await readApiError(res, "Failed to update provider"));
399
- return;
400
- }
401
- const updated = await parseJsonResponse(res, ProviderConfigSchema);
405
+ const updated = await fetchJsonWithTimeout(
406
+ `/api/providers/${editingProvider.id}`,
407
+ ProviderConfigSchema,
408
+ PROVIDER_WRITE_TIMEOUT_MS,
409
+ {
410
+ method: "PUT",
411
+ headers: { "Content-Type": "application/json" },
412
+ body: JSON.stringify(createProviderPayload(data)),
413
+ },
414
+ () => "Failed to update provider",
415
+ );
402
416
  setEditingProvider(undefined);
403
417
  triggerHighlight(updated.id);
404
418
  if (data.modelMetadataUrl !== undefined && data.modelMetadataUrl.trim() !== "") {
@@ -415,8 +429,8 @@ export function ProvidersPanel({
415
429
  if (criticalFieldsChanged) {
416
430
  await runTest(updated.id);
417
431
  }
418
- } catch {
419
- setError(NETWORK_ERROR_MESSAGE);
432
+ } catch (err) {
433
+ setError(err instanceof Error ? err.message : NETWORK_ERROR_MESSAGE);
420
434
  }
421
435
  })();
422
436
  }
@@ -432,7 +446,7 @@ export function ProvidersPanel({
432
446
  void (async () => {
433
447
  let res: Response;
434
448
  try {
435
- res = await fetch(`/api/providers/${providerId}`, {
449
+ res = await fetchWithTimeout(`/api/providers/${providerId}`, PROVIDER_DELETE_TIMEOUT_MS, {
436
450
  method: "DELETE",
437
451
  });
438
452
  } catch {
@@ -453,7 +467,7 @@ export function ProvidersPanel({
453
467
  const url = `/api/providers/export${includeKeys ? "?includeKeys=true" : ""}`;
454
468
  void (async () => {
455
469
  try {
456
- const res = await fetch(url);
470
+ const res = await fetchWithTimeout(url, PROVIDER_EXPORT_TIMEOUT_MS);
457
471
  if (!res.ok) {
458
472
  setError("Failed to export providers");
459
473
  return;
@@ -485,7 +499,7 @@ export function ProvidersPanel({
485
499
  void (async () => {
486
500
  try {
487
501
  const text = await file.text();
488
- const res = await fetch("/api/providers/import", {
502
+ const res = await fetchWithTimeout("/api/providers/import", PROVIDER_IMPORT_TIMEOUT_MS, {
489
503
  method: "POST",
490
504
  headers: { "Content-Type": "application/json" },
491
505
  body: JSON.stringify(text),
@@ -9,6 +9,7 @@ import { ProvidersPanel } from "./ProvidersPanel";
9
9
  import { useProviders } from "../../lib/useProviders";
10
10
  import { useStripConfig } from "../../lib/useStripConfig";
11
11
  import { copyTextToClipboard } from "../../lib/clipboard";
12
+ import { fetchJsonWithTimeout } from "../../lib/apiClient";
12
13
  import {
13
14
  MAX_PROVIDER_TEST_TIMEOUT_SECONDS,
14
15
  MAX_SLOW_RESPONSE_THRESHOLD_SECONDS,
@@ -148,6 +149,8 @@ const LogStorageStatsSchema = z.object({
148
149
  sessionArchiveBytes: z.number(),
149
150
  });
150
151
 
152
+ const STORAGE_STATS_FETCH_TIMEOUT_MS = 10_000;
153
+
151
154
  function formatBytes(bytes: number): string {
152
155
  if (bytes < 1024) return `${bytes} B`;
153
156
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
@@ -165,18 +168,14 @@ function StorageSettingsTab(): JSX.Element {
165
168
  setLoading(true);
166
169
  setError(null);
167
170
  try {
168
- const response = await fetch("/api/logs?stats=1");
169
- if (!response.ok) {
170
- setError(`Failed to load storage stats: HTTP ${String(response.status)}`);
171
- return;
172
- }
173
- const data: unknown = await response.json();
174
- const parsed = LogStorageStatsSchema.safeParse(data);
175
- if (!parsed.success) {
176
- setError("Storage stats response was not valid.");
177
- return;
178
- }
179
- setStats(parsed.data);
171
+ const data = await fetchJsonWithTimeout(
172
+ "/api/logs?stats=1",
173
+ LogStorageStatsSchema,
174
+ STORAGE_STATS_FETCH_TIMEOUT_MS,
175
+ undefined,
176
+ (response) => `Failed to load storage stats: HTTP ${String(response.status)}`,
177
+ );
178
+ setStats(data);
180
179
  } catch (err) {
181
180
  setError(err instanceof Error ? err.message : String(err));
182
181
  } finally {
@@ -23,7 +23,7 @@ import { z } from "zod";
23
23
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
24
24
  import { formatTimestampRange } from "../../lib/timeDisplay";
25
25
  import { cn, formatTokens } from "../../lib/utils";
26
- import { parseJsonResponse, readApiError } from "../../lib/apiClient";
26
+ import { fetchJsonWithTimeout, fetchWithTimeout } from "../../lib/apiClient";
27
27
  import {
28
28
  KnowledgeCandidateSchema,
29
29
  type KnowledgeCandidate,
@@ -64,6 +64,10 @@ type CandidateLoadState =
64
64
  | { status: "ready"; error: null }
65
65
  | { status: "failed"; error: string };
66
66
 
67
+ const CANDIDATE_GENERATE_TIMEOUT_MS = 30_000;
68
+ const CANDIDATE_PROMOTE_TIMEOUT_MS = 15_000;
69
+ const CANDIDATE_UPDATE_TIMEOUT_MS = 15_000;
70
+
67
71
  type TraceInsightKind = "session" | "tool" | "slow" | "failure" | "candidate";
68
72
 
69
73
  type TraceInsight = {
@@ -655,19 +659,13 @@ export function AgentTraceSummary({
655
659
  setCandidateState({ status: "loading", error: null });
656
660
  void (async () => {
657
661
  try {
658
- const response = await fetch(
662
+ const parsed = await fetchJsonWithTimeout(
659
663
  `/api/knowledge/sessions/${encodeURIComponent(scopeId)}/candidates`,
664
+ CandidateResponseSchema,
665
+ CANDIDATE_GENERATE_TIMEOUT_MS,
660
666
  { method: "POST" },
667
+ (response) => `Candidate generation failed with ${String(response.status)}`,
661
668
  );
662
- if (!response.ok) {
663
- const message = await readApiError(
664
- response,
665
- `Candidate generation failed with ${String(response.status)}`,
666
- );
667
- setCandidateState({ status: "failed", error: message });
668
- return;
669
- }
670
- const parsed = await parseJsonResponse(response, CandidateResponseSchema);
671
669
  setCandidates(parsed.candidates);
672
670
  setCandidatesExpanded(parsed.candidates.length > 0);
673
671
  setCandidateState(
@@ -694,8 +692,9 @@ export function AgentTraceSummary({
694
692
 
695
693
  void (async () => {
696
694
  try {
697
- const response = await fetch(
695
+ const response = await fetchWithTimeout(
698
696
  `/api/knowledge/candidates/${encodeURIComponent(candidateId)}/promote`,
697
+ CANDIDATE_PROMOTE_TIMEOUT_MS,
699
698
  { method: "POST" },
700
699
  );
701
700
  const raw: unknown = await response.json().catch(() => null);
@@ -749,24 +748,17 @@ export function AgentTraceSummary({
749
748
  });
750
749
 
751
750
  try {
752
- const response = await fetch(
751
+ const parsed = await fetchJsonWithTimeout(
753
752
  `/api/knowledge/candidates/${encodeURIComponent(candidateId)}`,
753
+ CandidateUpdateResponseSchema,
754
+ CANDIDATE_UPDATE_TIMEOUT_MS,
754
755
  {
755
756
  method: "PATCH",
756
757
  headers: { "Content-Type": "application/json" },
757
758
  body: JSON.stringify(update),
758
759
  },
760
+ (response) => `Candidate update failed with ${String(response.status)}`,
759
761
  );
760
- if (!response.ok) {
761
- const message = await readApiError(
762
- response,
763
- `Candidate update failed with ${String(response.status)}`,
764
- );
765
- setCandidateState({ status: "failed", error: message });
766
- return false;
767
- }
768
-
769
- const parsed = await parseJsonResponse(response, CandidateUpdateResponseSchema);
770
762
  setCandidates((current) =>
771
763
  current.map((candidate) =>
772
764
  candidate.id === parsed.candidate.id ? parsed.candidate : candidate,
@@ -1,6 +1,6 @@
1
1
  import { Braces, Minimize2, RotateCcw } from "lucide-react";
2
2
  import type { JSX } from "react";
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { z } from "zod";
5
5
  import { useProviders } from "../../lib/useProviders";
6
6
  import { Button } from "../ui/button";
@@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
9
9
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
10
10
  import { ResponseView } from "./ResponseView";
11
11
  import { CapturedLogSchema, type CapturedLog } from "../../contracts";
12
+ import { ApiTimeoutError, fetchJsonWithTimeout } from "../../lib/apiClient";
12
13
  import { dispatchLogFocusRequest } from "./logFocus";
13
14
  import {
14
15
  buildReplayComparisons,
@@ -53,6 +54,9 @@ const ReplayHistorySchema = z.object({
53
54
  total: z.number(),
54
55
  });
55
56
 
57
+ const REPLAY_TIMEOUT_MS = 60_000;
58
+ const REPLAY_HISTORY_TIMEOUT_MS = 10_000;
59
+
56
60
  function formatJsonForEdit(value: string): string {
57
61
  try {
58
62
  const parsed: unknown = JSON.parse(value);
@@ -175,17 +179,26 @@ export function ReplayDialog({
175
179
  const [replayHistory, setReplayHistory] = useState<CapturedLog[]>([]);
176
180
  const [loading, setLoading] = useState(false);
177
181
  const [error, setError] = useState<string | null>(null);
182
+ const replayAbortRef = useRef<AbortController | null>(null);
183
+ const replayRequestIdRef = useRef(0);
178
184
  const jsonEditState = useMemo(() => readJsonEditState(modifiedBody), [modifiedBody]);
179
185
 
186
+ const cancelReplay = useCallback(() => {
187
+ replayRequestIdRef.current += 1;
188
+ replayAbortRef.current?.abort();
189
+ replayAbortRef.current = null;
190
+ setLoading(false);
191
+ }, []);
192
+
180
193
  const refreshReplayHistory = useCallback(async (): Promise<void> => {
181
194
  if (!open) return;
182
195
  try {
183
- const response = await fetch(`/api/logs?replayOf=${String(log.id)}`);
184
- if (!response.ok) return;
185
- const data: unknown = await response.json();
186
- const parsed = ReplayHistorySchema.safeParse(data);
187
- if (!parsed.success) return;
188
- setReplayHistory(parsed.data.logs);
196
+ const data = await fetchJsonWithTimeout(
197
+ `/api/logs?replayOf=${String(log.id)}`,
198
+ ReplayHistorySchema,
199
+ REPLAY_HISTORY_TIMEOUT_MS,
200
+ );
201
+ setReplayHistory(data.logs);
189
202
  } catch {
190
203
  // Replay history is helpful context, not required for replay itself.
191
204
  }
@@ -195,44 +208,74 @@ export function ReplayDialog({
195
208
  void refreshReplayHistory();
196
209
  }, [refreshReplayHistory]);
197
210
 
211
+ useEffect(() => {
212
+ if (open) return undefined;
213
+ cancelReplay();
214
+ return undefined;
215
+ }, [cancelReplay, open]);
216
+
217
+ useEffect(() => {
218
+ return () => {
219
+ replayRequestIdRef.current += 1;
220
+ replayAbortRef.current?.abort();
221
+ replayAbortRef.current = null;
222
+ };
223
+ }, []);
224
+
198
225
  async function handleReplay() {
199
226
  if (!jsonEditState.valid) {
200
227
  setError("Request body must be valid JSON before replay.");
201
228
  return;
202
229
  }
203
230
 
231
+ replayAbortRef.current?.abort();
232
+ const requestId = replayRequestIdRef.current + 1;
233
+ replayRequestIdRef.current = requestId;
234
+ const controller = new AbortController();
235
+ replayAbortRef.current = controller;
236
+
204
237
  setLoading(true);
205
238
  setError(null);
206
239
  setReplayResult(null);
207
240
 
208
241
  try {
209
- const res = await fetch(`/api/logs/${log.id}/replay`, {
210
- method: "POST",
211
- headers: { "Content-Type": "application/json" },
212
- body: JSON.stringify({ modifiedBody }),
213
- });
214
-
215
- const json: unknown = await res.json();
216
- const parsed = ReplayResultSchema.safeParse(json);
217
- if (!parsed.success) {
218
- setError("Invalid response from server");
219
- setLoading(false);
220
- return;
221
- }
222
- const data = parsed.data;
242
+ const data = await fetchJsonWithTimeout(
243
+ `/api/logs/${log.id}/replay`,
244
+ ReplayResultSchema,
245
+ REPLAY_TIMEOUT_MS,
246
+ {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ modifiedBody }),
250
+ signal: controller.signal,
251
+ },
252
+ );
253
+ if (replayRequestIdRef.current !== requestId) return;
223
254
  setReplayResult(data);
224
255
  await refreshReplayHistory();
225
256
  if (!data.success) {
226
257
  setError(data.error ?? "Replay failed");
227
258
  }
228
259
  } catch (err) {
260
+ if (replayRequestIdRef.current !== requestId) return;
261
+ if (
262
+ err instanceof ApiTimeoutError ||
263
+ (err instanceof DOMException && err.name === "AbortError")
264
+ ) {
265
+ setError("Replay timed out or was canceled.");
266
+ return;
267
+ }
229
268
  setError(err instanceof Error ? err.message : "Network error");
230
269
  } finally {
231
- setLoading(false);
270
+ if (replayRequestIdRef.current === requestId) {
271
+ replayAbortRef.current = null;
272
+ setLoading(false);
273
+ }
232
274
  }
233
275
  }
234
276
 
235
277
  function handleClose() {
278
+ cancelReplay();
236
279
  setReplayResult(null);
237
280
  setError(null);
238
281
  onOpenChange(false);
@@ -1,8 +1,10 @@
1
- import { memo, Suspense, useState, useEffect, useMemo, type JSX } from "react";
1
+ import { memo, Suspense, useState, useEffect, useMemo, useRef, type JSX } from "react";
2
2
  import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
3
+ import { z } from "zod";
3
4
  import { Badge } from "../ui/badge";
4
5
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
5
- import type { StreamingChunk } from "../../contracts";
6
+ import { StreamingChunkSchema, type StreamingChunk } from "../../contracts";
7
+ import { fetchJsonWithTimeout } from "../../lib/apiClient";
6
8
  import { LazyJsonViewer } from "./lazy";
7
9
 
8
10
  export type StreamingChunkSequenceProps = {
@@ -21,6 +23,13 @@ type ChunkGroup = {
21
23
  chunks: StreamingChunk[];
22
24
  };
23
25
 
26
+ const StreamingChunksResponseSchema = z.object({
27
+ chunks: z.array(StreamingChunkSchema),
28
+ truncated: z.boolean().optional(),
29
+ });
30
+
31
+ const CHUNK_FETCH_TIMEOUT_MS = 10_000;
32
+
24
33
  export const StreamingChunkSequence = memo(function StreamingChunkSequence({
25
34
  logId,
26
35
  truncated,
@@ -28,33 +37,37 @@ export const StreamingChunkSequence = memo(function StreamingChunkSequence({
28
37
  const [containerExpanded, setContainerExpanded] = useState(false);
29
38
  const [chunkState, setChunkState] = useState<ChunkState>({ status: "idle" });
30
39
  const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
40
+ const loadedLogIdRef = useRef<number | null>(null);
31
41
 
32
42
  useEffect(() => {
33
- if (!containerExpanded || chunkState.status !== "idle") return;
43
+ if (!containerExpanded) return;
44
+ if (loadedLogIdRef.current === logId) return;
34
45
 
35
- let cancelled = false;
46
+ const controller = new AbortController();
36
47
  setChunkState({ status: "loading" });
37
48
 
38
- fetch(`/api/logs/${logId}/chunks`)
39
- .then((res) => {
40
- if (!res.ok) {
41
- return Promise.reject(new Error("Chunks not found"));
42
- }
43
- return res.json();
44
- })
45
- .then((data: { chunks: StreamingChunk[]; truncated?: boolean }) => {
46
- if (!cancelled) {
47
- setChunkState({ status: "success", chunks: data.chunks });
48
- }
49
+ void fetchJsonWithTimeout(
50
+ `/api/logs/${logId}/chunks`,
51
+ StreamingChunksResponseSchema,
52
+ CHUNK_FETCH_TIMEOUT_MS,
53
+ { signal: controller.signal },
54
+ () => "Chunks not found",
55
+ )
56
+ .then((data) => {
57
+ loadedLogIdRef.current = logId;
58
+ setChunkState({ status: "success", chunks: data.chunks });
49
59
  })
50
- .catch(() => {
51
- if (!cancelled) {
52
- setChunkState({ status: "error", message: "Chunk data unavailable" });
53
- }
60
+ .catch((error: unknown) => {
61
+ if (error instanceof DOMException && error.name === "AbortError") return;
62
+ loadedLogIdRef.current = null;
63
+ setChunkState({
64
+ status: "error",
65
+ message: error instanceof Error ? error.message : "Chunk data unavailable",
66
+ });
54
67
  });
55
68
 
56
69
  return () => {
57
- cancelled = true;
70
+ controller.abort();
58
71
  };
59
72
  }, [containerExpanded, logId]);
60
73
 
@@ -217,13 +217,6 @@ export const TurnGroup = memo(function TurnGroup({
217
217
  }
218
218
  }}
219
219
  >
220
- {/* Turn number */}
221
- <div className="w-5 shrink-0 flex items-start pt-1.5">
222
- <span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
223
- {turnIndex + 1}
224
- </span>
225
- </div>
226
-
227
220
  <div className="w-6 shrink-0 flex flex-col items-center pt-1.5 pb-1.5">
228
221
  <div className="flex justify-center h-[calc(0.75rem-8px)]" />
229
222
  <CollapsedCrab
@@ -344,15 +337,6 @@ export const TurnGroup = memo(function TurnGroup({
344
337
 
345
338
  return (
346
339
  <div key={log.id} className="flex items-stretch">
347
- {isTurnStart ? (
348
- <div className="w-5 shrink-0 flex items-start pt-1.5">
349
- <span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
350
- {turnIndex + 1}
351
- </span>
352
- </div>
353
- ) : (
354
- <div className="w-5 shrink-0" />
355
- )}
356
340
  <ThreadConnector
357
341
  stopReason={reason}
358
342
  isPending={log.responseStatus === null}
@@ -1,6 +1,7 @@
1
1
  import { AnthropicRequestSchema, AnthropicResponseSchema } from "../../../contracts/anthropic";
2
2
  import type { AnatomySegment } from "../anatomy/types";
3
3
  import { countCharacters, estimateTokens } from "../anatomy/tokenEstimate";
4
+ import { parseRequestTools } from "../requestTools";
4
5
  import type { LogFormatAdapter } from "./types";
5
6
  import { emptyRequestAnalysis, EMPTY_RESPONSE_ANALYSIS } from "./types";
6
7
 
@@ -113,16 +114,15 @@ export const anthropicLogFormatAdapter: LogFormatAdapter = {
113
114
  analyzeRequest(rawBody) {
114
115
  if (rawBody === null) return emptyRequestAnalysis(rawBody);
115
116
  try {
116
- const result = AnthropicRequestSchema.safeParse(JSON.parse(rawBody));
117
+ const parsedBody: unknown = JSON.parse(rawBody);
118
+ const toolCount = parseRequestTools(parsedBody)?.tools.length ?? null;
119
+ const result = AnthropicRequestSchema.safeParse(parsedBody);
117
120
  if (!result.success) return emptyRequestAnalysis(rawBody);
118
121
  return {
119
122
  parsed: result.data,
120
123
  comparisonValue: result.data,
121
124
  messageCount: result.data.messages.length,
122
- toolCount:
123
- result.data.tools !== undefined && result.data.tools.length > 0
124
- ? result.data.tools.length
125
- : null,
125
+ toolCount,
126
126
  };
127
127
  } catch {
128
128
  return emptyRequestAnalysis(rawBody);