@agent-native/dispatch 0.16.1 → 0.16.3

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 (38) hide show
  1. package/dist/actions/get-agent-thread-debug.d.ts +9 -0
  2. package/dist/actions/index.d.ts.map +1 -1
  3. package/dist/actions/index.js +2 -0
  4. package/dist/actions/index.js.map +1 -1
  5. package/dist/actions/list-agent-run-failures.d.ts +61 -0
  6. package/dist/actions/list-agent-run-failures.d.ts.map +1 -0
  7. package/dist/actions/list-agent-run-failures.js +38 -0
  8. package/dist/actions/list-agent-run-failures.js.map +1 -0
  9. package/dist/actions/provider-api-register.d.ts +12 -12
  10. package/dist/actions/upsert-destination.d.ts +12 -12
  11. package/dist/actions/view-screen.js +32 -5
  12. package/dist/actions/view-screen.js.map +1 -1
  13. package/dist/hooks/use-navigation-state.d.ts +6 -0
  14. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  15. package/dist/hooks/use-navigation-state.js +30 -0
  16. package/dist/hooks/use-navigation-state.js.map +1 -1
  17. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  18. package/dist/routes/pages/thread-debug.js +291 -77
  19. package/dist/routes/pages/thread-debug.js.map +1 -1
  20. package/dist/server/lib/thread-debug-store.d.ts +72 -0
  21. package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
  22. package/dist/server/lib/thread-debug-store.js +215 -19
  23. package/dist/server/lib/thread-debug-store.js.map +1 -1
  24. package/dist/server/plugins/agent-chat.js +1 -0
  25. package/dist/server/plugins/agent-chat.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/actions/index.ts +2 -0
  28. package/src/actions/list-agent-run-failures.ts +44 -0
  29. package/src/actions/view-screen.spec.ts +91 -0
  30. package/src/actions/view-screen.ts +34 -4
  31. package/src/hooks/use-navigation-state.spec.ts +45 -0
  32. package/src/hooks/use-navigation-state.ts +28 -0
  33. package/src/routes/pages/thread-debug.spec.tsx +272 -0
  34. package/src/routes/pages/thread-debug.tsx +843 -224
  35. package/src/server/lib/thread-debug-store.spec.ts +292 -16
  36. package/src/server/lib/thread-debug-store.ts +318 -22
  37. package/src/server/plugins/agent-chat.spec.ts +30 -0
  38. package/src/server/plugins/agent-chat.ts +1 -0
@@ -1,12 +1,14 @@
1
- import { agentNativePath } from "@agent-native/core/client/api-path";
2
1
  import { useActionQuery } from "@agent-native/core/client/hooks";
2
+ import { useT } from "@agent-native/core/client/i18n";
3
3
  import {
4
+ IconAlertTriangle,
5
+ IconClock,
4
6
  IconDatabase,
5
7
  IconFileSearch,
6
8
  IconRefresh,
7
9
  IconSearch,
8
10
  } from "@tabler/icons-react";
9
- import { useEffect, useMemo, useState } from "react";
11
+ import { useMemo, useState } from "react";
10
12
  import { useSearchParams } from "react-router";
11
13
 
12
14
  import { ActionQueryError } from "../../components/action-query-error";
@@ -71,13 +73,78 @@ interface ThreadMessage {
71
73
  interface ThreadRun {
72
74
  id: string;
73
75
  status: string;
76
+ turnId?: string | null;
74
77
  abortReason: string | null;
78
+ errorCode?: string | null;
79
+ errorDetail?: string | null;
80
+ terminalReason?: string | null;
81
+ dispatchMode?: string | null;
82
+ diagStage?: string | null;
83
+ workerStage?: string | null;
75
84
  startedAt: number;
76
85
  completedAt: number | null;
77
86
  heartbeatAt: number | null;
87
+ lastProgressAt?: number | null;
88
+ durationMs?: number | null;
89
+ peakRssMb?: number | null;
78
90
  events: Array<{ seq: number; event: any; rawEventData: string }>;
79
91
  }
80
92
 
93
+ type ThreadDebugMode = "failures" | "threads";
94
+ type FailureStatus = "all" | "errored" | "aborted" | "truncated";
95
+ type FailureRange = "24h" | "7d" | "30d";
96
+
97
+ interface AgentRunFailure {
98
+ id: string;
99
+ threadId: string;
100
+ sourceId?: string;
101
+ sourceLabel?: string;
102
+ source?: {
103
+ id: string;
104
+ label: string;
105
+ kind?: string;
106
+ databaseUrlEnv?: string | null;
107
+ };
108
+ ownerEmail: string;
109
+ threadTitle: string;
110
+ threadPreview: string;
111
+ status: string;
112
+ errorCode: string | null;
113
+ errorDetail: string | null;
114
+ terminalReason: string | null;
115
+ abortReason: string | null;
116
+ dispatchMode: string | null;
117
+ diagStage: string | null;
118
+ workerStage?: string | null;
119
+ startedAt: number;
120
+ completedAt: number | null;
121
+ durationMs: number | null;
122
+ }
123
+
124
+ interface AgentRunFailuresResponse {
125
+ failures: AgentRunFailure[];
126
+ sources: Array<{
127
+ source: {
128
+ id: string;
129
+ label: string;
130
+ kind?: string;
131
+ databaseUrlEnv?: string | null;
132
+ };
133
+ status: "ok" | "unsupported" | "unavailable" | "disconnected";
134
+ failureCount: number;
135
+ errorCode?: string | null;
136
+ }>;
137
+ partial: boolean;
138
+ count?: number;
139
+ access: { viewerEmail: string; scope: string; canInspectAll: boolean };
140
+ filters?: {
141
+ sourceId?: string;
142
+ status?: FailureStatus;
143
+ lookbackHours?: number;
144
+ limit?: number;
145
+ };
146
+ }
147
+
81
148
  interface ThreadDebugResponse {
82
149
  source: {
83
150
  id: string;
@@ -102,6 +169,36 @@ interface ThreadDebugResponse {
102
169
  checkpoints: any[];
103
170
  }
104
171
 
172
+ const FAILURE_RANGE_HOURS: Record<FailureRange, number> = {
173
+ "24h": 24,
174
+ "7d": 7 * 24,
175
+ "30d": 30 * 24,
176
+ };
177
+
178
+ function parseMode(value: string | null): ThreadDebugMode {
179
+ return value === "threads" ? "threads" : "failures";
180
+ }
181
+
182
+ function parseFailureStatus(value: string | null): FailureStatus {
183
+ return value === "errored" || value === "aborted" || value === "truncated"
184
+ ? value
185
+ : "all";
186
+ }
187
+
188
+ function parseFailureRange(value: string | null): FailureRange {
189
+ return value === "7d" || value === "30d" ? value : "24h";
190
+ }
191
+
192
+ function failureSourceId(failure: AgentRunFailure): string {
193
+ return failure.sourceId || failure.source?.id || "current";
194
+ }
195
+
196
+ function failureSourceLabel(failure: AgentRunFailure): string {
197
+ return (
198
+ failure.sourceLabel || failure.source?.label || failureSourceId(failure)
199
+ );
200
+ }
201
+
105
202
  function formatDate(value: number | string | null | undefined): string {
106
203
  if (value == null || value === "") return "n/a";
107
204
  const numeric = Number(value);
@@ -110,6 +207,13 @@ function formatDate(value: number | string | null | undefined): string {
110
207
  return date.toLocaleString();
111
208
  }
112
209
 
210
+ function formatDuration(value: number | null | undefined): string {
211
+ if (value == null || !Number.isFinite(value)) return "n/a";
212
+ if (value < 1_000) return `${Math.round(value)}ms`;
213
+ if (value < 60_000) return `${(value / 1_000).toFixed(1)}s`;
214
+ return `${(value / 60_000).toFixed(1)}m`;
215
+ }
216
+
113
217
  function json(value: unknown): string {
114
218
  try {
115
219
  return JSON.stringify(value, null, 2);
@@ -136,6 +240,24 @@ function toolParts(message: ThreadMessage): any[] {
136
240
  return message.contentParts.filter((part) => part?.type === "tool-call");
137
241
  }
138
242
 
243
+ function diagnosticStage(value: string | null | undefined): string | null {
244
+ if (!value) return null;
245
+ try {
246
+ const parsed = JSON.parse(value) as { stage?: unknown; detail?: unknown };
247
+ const stage =
248
+ typeof parsed.stage === "string" && parsed.stage.trim()
249
+ ? parsed.stage.trim()
250
+ : value;
251
+ const detail =
252
+ typeof parsed.detail === "string" && parsed.detail.trim()
253
+ ? parsed.detail.trim()
254
+ : "";
255
+ return detail ? `${stage}: ${detail}` : stage;
256
+ } catch {
257
+ return value;
258
+ }
259
+ }
260
+
139
261
  function RawBlock({
140
262
  value,
141
263
  className,
@@ -208,6 +330,90 @@ function ResultCard({
208
330
  );
209
331
  }
210
332
 
333
+ function FailureCard({
334
+ failure,
335
+ selected,
336
+ onSelect,
337
+ }: {
338
+ failure: AgentRunFailure;
339
+ selected: boolean;
340
+ onSelect: () => void;
341
+ }) {
342
+ const t = useT();
343
+ const summary =
344
+ failure.errorCode ||
345
+ failure.terminalReason ||
346
+ failure.abortReason ||
347
+ failure.status;
348
+ const statusLabel =
349
+ failure.status === "errored"
350
+ ? t("dispatch.pages.threadDebugErrored", {
351
+ defaultValue: "Errored",
352
+ })
353
+ : failure.status === "aborted"
354
+ ? t("dispatch.pages.threadDebugAborted", {
355
+ defaultValue: "Aborted",
356
+ })
357
+ : failure.status === "truncated"
358
+ ? t("dispatch.pages.threadDebugTruncated", {
359
+ defaultValue: "Truncated",
360
+ })
361
+ : failure.status;
362
+
363
+ return (
364
+ <button
365
+ type="button"
366
+ onClick={onSelect}
367
+ className={cn(
368
+ "w-full rounded-lg border px-3 py-3 text-left transition-colors",
369
+ selected
370
+ ? "border-foreground bg-muted"
371
+ : "bg-card hover:border-foreground/30 hover:bg-muted/40",
372
+ )}
373
+ >
374
+ <div className="flex items-start justify-between gap-3">
375
+ <div className="min-w-0">
376
+ <div className="truncate text-sm font-medium text-foreground">
377
+ {failure.threadTitle || failure.threadPreview || failure.threadId}
378
+ </div>
379
+ <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
380
+ {failure.id}
381
+ </div>
382
+ </div>
383
+ <Badge variant="outline" className="shrink-0">
384
+ {statusLabel}
385
+ </Badge>
386
+ </div>
387
+ <div className="mt-2 flex items-center gap-1.5 text-xs text-foreground">
388
+ <IconAlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
389
+ <span className="truncate">{summary}</span>
390
+ </div>
391
+ {failure.errorDetail ? (
392
+ <div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
393
+ {failure.errorDetail}
394
+ </div>
395
+ ) : null}
396
+ <div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
397
+ <span className="truncate">
398
+ {failureSourceLabel(failure)} · {failure.ownerEmail}
399
+ </span>
400
+ <span className="inline-flex shrink-0 items-center gap-1">
401
+ <IconClock className="size-3" />
402
+ {formatDate(failure.completedAt ?? failure.startedAt)}
403
+ {failure.durationMs == null
404
+ ? null
405
+ : ` · ${formatDuration(failure.durationMs)}`}
406
+ </span>
407
+ </div>
408
+ <span className="sr-only">
409
+ {t("dispatch.pages.threadDebugInspectFailure", {
410
+ defaultValue: "Inspect failed run",
411
+ })}
412
+ </span>
413
+ </button>
414
+ );
415
+ }
416
+
211
417
  function MessageBlock({ message }: { message: ThreadMessage }) {
212
418
  const tools = toolParts(message);
213
419
  return (
@@ -259,6 +465,7 @@ function MessageBlock({ message }: { message: ThreadMessage }) {
259
465
  }
260
466
 
261
467
  function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
468
+ const t = useT();
262
469
  const rawBundle = useMemo(
263
470
  () => ({
264
471
  thread: detail.thread,
@@ -334,12 +541,91 @@ function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
334
541
  <span className="font-mono text-xs text-foreground">
335
542
  {run.id}
336
543
  </span>
544
+ {run.errorCode ? (
545
+ <span className="font-mono text-xs text-destructive">
546
+ {run.errorCode}
547
+ </span>
548
+ ) : null}
337
549
  <span className="text-xs text-muted-foreground">
338
550
  {formatDate(run.startedAt)}
339
551
  </span>
340
552
  </div>
341
553
  </summary>
342
- <div className="space-y-2 border-t px-4 py-3">
554
+ <div className="space-y-3 border-t px-4 py-3">
555
+ <div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-3">
556
+ <div>
557
+ <div className="text-muted-foreground">
558
+ {t("dispatch.pages.threadDebugFailureCode", {
559
+ defaultValue: "Failure code",
560
+ })}
561
+ </div>
562
+ <div className="mt-0.5 break-words font-mono text-foreground">
563
+ {run.errorCode || "n/a"}
564
+ </div>
565
+ </div>
566
+ <div>
567
+ <div className="text-muted-foreground">
568
+ {t("dispatch.pages.threadDebugTerminalReason", {
569
+ defaultValue: "Terminal reason",
570
+ })}
571
+ </div>
572
+ <div className="mt-0.5 break-words font-mono text-foreground">
573
+ {run.terminalReason || run.abortReason || "n/a"}
574
+ </div>
575
+ </div>
576
+ <div>
577
+ <div className="text-muted-foreground">
578
+ {t("dispatch.pages.threadDebugDispatchMode", {
579
+ defaultValue: "Dispatch mode",
580
+ })}
581
+ </div>
582
+ <div className="mt-0.5 break-words font-mono text-foreground">
583
+ {run.dispatchMode || "foreground"}
584
+ </div>
585
+ </div>
586
+ <div>
587
+ <div className="text-muted-foreground">
588
+ {t("dispatch.pages.threadDebugLastStage", {
589
+ defaultValue: "Last stage",
590
+ })}
591
+ </div>
592
+ <div className="mt-0.5 break-words font-mono text-foreground">
593
+ {diagnosticStage(run.workerStage) ||
594
+ diagnosticStage(run.diagStage) ||
595
+ "n/a"}
596
+ </div>
597
+ </div>
598
+ <div>
599
+ <div className="text-muted-foreground">
600
+ {t("dispatch.pages.threadDebugDuration", {
601
+ defaultValue: "Duration",
602
+ })}
603
+ </div>
604
+ <div className="mt-0.5 text-foreground">
605
+ {formatDuration(
606
+ run.durationMs ??
607
+ (run.completedAt == null
608
+ ? null
609
+ : run.completedAt - run.startedAt),
610
+ )}
611
+ </div>
612
+ </div>
613
+ <div>
614
+ <div className="text-muted-foreground">
615
+ {t("dispatch.pages.threadDebugLastProgress", {
616
+ defaultValue: "Last progress",
617
+ })}
618
+ </div>
619
+ <div className="mt-0.5 text-foreground">
620
+ {formatDate(run.lastProgressAt ?? run.heartbeatAt)}
621
+ </div>
622
+ </div>
623
+ </div>
624
+ {run.errorDetail ? (
625
+ <div className="rounded-md bg-destructive/10 px-3 py-2 text-xs leading-relaxed text-destructive">
626
+ {run.errorDetail}
627
+ </div>
628
+ ) : null}
343
629
  {run.events.map((event) => (
344
630
  <details
345
631
  key={`${run.id}-${event.seq}`}
@@ -418,25 +704,31 @@ function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
418
704
  }
419
705
 
420
706
  export default function ThreadDebugRoute() {
421
- const [routeSearchParams] = useSearchParams();
422
- const initialSourceId = routeSearchParams.get("source") || "current";
423
- const initialQuery = routeSearchParams.get("query") || "";
424
- const initialOwnerEmail = routeSearchParams.get("ownerEmail") || "";
425
- const [sourceId, setSourceId] = useState(initialSourceId);
426
- const [query, setQuery] = useState(initialQuery);
427
- const [ownerEmail, setOwnerEmail] = useState(initialOwnerEmail);
707
+ const t = useT();
708
+ const [routeSearchParams, setRouteSearchParams] = useSearchParams();
709
+ const mode = parseMode(routeSearchParams.get("mode"));
710
+ const sourceId =
711
+ routeSearchParams.get("source") ||
712
+ (mode === "failures" ? "all" : "current");
713
+ const ownerEmail = routeSearchParams.get("owner") || "";
714
+ const query = routeSearchParams.get("query") || "";
715
+ const status = parseFailureStatus(routeSearchParams.get("status"));
716
+ const range = parseFailureRange(routeSearchParams.get("range"));
717
+ const runId = routeSearchParams.get("runId") || "";
718
+ const threadId = routeSearchParams.get("threadId") || "";
719
+ const inspectSourceId = routeSearchParams.get("inspectSource") || "";
428
720
  const [lookupId, setLookupId] = useState("");
429
- const [submittedSearch, setSubmittedSearch] = useState({
430
- sourceId: initialSourceId,
431
- query: initialQuery,
432
- ownerEmail: initialOwnerEmail,
433
- });
434
- const [selected, setSelected] = useState<{
435
- sourceId: string;
436
- lookupId: string;
437
- lookupKind: "thread" | "run";
438
- ownerEmail?: string;
439
- } | null>(null);
721
+
722
+ function updateRouteState(
723
+ updates: Record<string, string | null | undefined>,
724
+ ) {
725
+ const next = new URLSearchParams(routeSearchParams);
726
+ for (const [key, value] of Object.entries(updates)) {
727
+ if (value == null || value === "") next.delete(key);
728
+ else next.set(key, value);
729
+ }
730
+ setRouteSearchParams(next, { replace: true });
731
+ }
440
732
 
441
733
  const sourcesQuery = useActionQuery<{
442
734
  access: {
@@ -452,14 +744,54 @@ export default function ThreadDebugRoute() {
452
744
  const { data: sourcesData, isLoading: sourcesLoading } = sourcesQuery;
453
745
 
454
746
  const sources: ThreadDebugSource[] = sourcesData?.sources ?? [];
747
+ const failureParams = useMemo(
748
+ () => ({
749
+ sourceId,
750
+ ownerEmail: ownerEmail.trim() || undefined,
751
+ status,
752
+ lookbackHours: FAILURE_RANGE_HOURS[range],
753
+ limit: 25,
754
+ }),
755
+ [ownerEmail, range, sourceId, status],
756
+ );
757
+ const {
758
+ data: failuresData,
759
+ isLoading: failuresLoading,
760
+ error: failuresError,
761
+ refetch: refetchFailures,
762
+ } = useActionQuery<AgentRunFailuresResponse>(
763
+ "list-agent-run-failures",
764
+ failureParams,
765
+ { enabled: mode === "failures" },
766
+ );
767
+ const failures = failuresData?.failures ?? [];
768
+ const unavailableFailureSources = (failuresData?.sources ?? []).filter(
769
+ (source) => source.status !== "ok",
770
+ );
771
+ const failureSourceStatusLabels = {
772
+ ok: "ok",
773
+ disconnected: t("dispatch.pages.threadDebugDisconnected", {
774
+ defaultValue: "disconnected",
775
+ }),
776
+ unsupported: t("dispatch.pages.threadDebugUnsupported", {
777
+ defaultValue: "unsupported",
778
+ }),
779
+ unavailable: t("dispatch.pages.threadDebugUnavailable", {
780
+ defaultValue: "unavailable",
781
+ }),
782
+ };
783
+
784
+ const threadSourceId = sourceId === "all" ? "current" : sourceId;
785
+ const detailSourceId =
786
+ runId && inspectSourceId ? inspectSourceId : threadSourceId;
455
787
  const searchParams = useMemo(
456
788
  () => ({
457
- sourceId: submittedSearch.sourceId,
458
- query: submittedSearch.query || undefined,
459
- ownerEmail: submittedSearch.ownerEmail || undefined,
789
+ sourceId: threadSourceId,
790
+ query: query.trim() || undefined,
791
+ ownerEmail: ownerEmail.trim() || undefined,
460
792
  limit: 25,
461
793
  }),
462
- [submittedSearch],
794
+ [ownerEmail, query, threadSourceId],
463
795
  );
464
796
  const {
465
797
  data: searchData,
@@ -471,21 +803,19 @@ export default function ThreadDebugRoute() {
471
803
  threads: ThreadSearchResult[];
472
804
  access: { scope: string; canInspectAll: boolean };
473
805
  source: { id: string; label: string };
474
- }>("search-agent-threads", searchParams);
806
+ }>("search-agent-threads", searchParams, { enabled: mode === "threads" });
475
807
  const searchThreads: ThreadSearchResult[] = searchData?.threads ?? [];
476
808
 
477
809
  const detailParams = useMemo(
478
810
  () => ({
479
- sourceId: selected?.sourceId ?? "current",
480
- ...(selected?.lookupKind === "run"
481
- ? { runId: selected.lookupId }
482
- : { threadId: selected?.lookupId ?? "" }),
483
- ownerEmail: selected?.ownerEmail,
811
+ sourceId: detailSourceId,
812
+ ...(runId ? { runId } : { threadId }),
813
+ ownerEmail: ownerEmail.trim() || undefined,
484
814
  maxRuns: 20,
485
815
  maxEvents: 800,
486
816
  maxTraceSpans: 600,
487
817
  }),
488
- [selected],
818
+ [detailSourceId, ownerEmail, runId, threadId],
489
819
  );
490
820
  const {
491
821
  data: detail,
@@ -496,46 +826,47 @@ export default function ThreadDebugRoute() {
496
826
  "get-agent-thread-debug",
497
827
  detailParams,
498
828
  {
499
- enabled: Boolean(selected?.lookupId),
829
+ enabled: Boolean(runId || threadId),
500
830
  },
501
831
  );
502
832
 
503
- const selectedSource = sources.find((source) => source.id === sourceId);
504
-
505
- useEffect(() => {
506
- fetch(agentNativePath("/_agent-native/application-state/navigation"), {
507
- method: "PUT",
508
- keepalive: true,
509
- headers: { "Content-Type": "application/json" },
510
- body: JSON.stringify({
511
- view: "thread-debug",
512
- path:
513
- typeof window === "undefined"
514
- ? "/thread-debug"
515
- : window.location.pathname,
516
- sourceId,
517
- query,
518
- ownerEmail: ownerEmail.trim() || undefined,
519
- threadId:
520
- selected?.lookupKind === "thread"
521
- ? selected.lookupId
522
- : !selected && lookupId.trim() && !lookupId.startsWith("run-")
523
- ? lookupId.trim()
524
- : undefined,
525
- runId:
526
- selected?.lookupKind === "run"
527
- ? selected.lookupId
528
- : !selected && lookupId.trim() && lookupId.startsWith("run-")
529
- ? lookupId.trim()
530
- : undefined,
531
- }),
532
- }).catch(() => {});
533
- }, [ownerEmail, query, selected, sourceId, lookupId]);
833
+ const selectedSource = sources.find((source) => source.id === threadSourceId);
834
+ const detailPane = (
835
+ <section className="min-w-0">
836
+ {detailError ? (
837
+ <ActionQueryError
838
+ error={detailError}
839
+ onRetry={() => void refetchDetail()}
840
+ />
841
+ ) : null}
842
+ {detailLoading ? (
843
+ <div className="rounded-lg bg-card p-4">
844
+ <Skeleton className="h-6 w-72" />
845
+ <Skeleton className="mt-3 h-4 w-96" />
846
+ <Skeleton className="mt-6 h-[520px] w-full" />
847
+ </div>
848
+ ) : detail ? (
849
+ <ThreadDetail detail={detail} />
850
+ ) : (
851
+ <div className="flex min-h-[520px] flex-col items-center justify-center rounded-lg border border-dashed bg-card px-4 text-center text-sm text-muted-foreground">
852
+ <IconFileSearch className="mb-2 size-5" />
853
+ {t("dispatch.pages.threadDebugSelectPrompt", {
854
+ defaultValue: "Select a failed run or thread to inspect.",
855
+ })}
856
+ </div>
857
+ )}
858
+ </section>
859
+ );
534
860
 
535
861
  return (
536
862
  <DispatchShell
537
- title="Thread Debug"
538
- description="Inspect persisted agent chat threads, run events, and AI internals."
863
+ title={t("dispatch.pages.threadDebugTitle", {
864
+ defaultValue: "Thread Debug",
865
+ })}
866
+ description={t("dispatch.pages.threadDebugDescription", {
867
+ defaultValue:
868
+ "Inspect failed agent runs, persisted threads, run events, and AI internals.",
869
+ })}
539
870
  >
540
871
  <div className="space-y-4">
541
872
  {sourcesQuery.isError ? (
@@ -544,178 +875,466 @@ export default function ThreadDebugRoute() {
544
875
  onRetry={() => void sourcesQuery.refetch()}
545
876
  />
546
877
  ) : null}
547
- <section className="rounded-lg bg-card p-4">
548
- <div className="grid gap-3 lg:grid-cols-[220px_1fr_260px_auto]">
549
- <Select value={sourceId} onValueChange={setSourceId}>
550
- <SelectTrigger>
551
- <SelectValue placeholder="Source" />
552
- </SelectTrigger>
553
- <SelectContent>
554
- {sources.map((source) => (
555
- <SelectItem key={source.id} value={source.id}>
556
- {source.label}
557
- </SelectItem>
558
- ))}
559
- {sources.length === 0 ? (
560
- <SelectItem value="current">Current Dispatch DB</SelectItem>
561
- ) : null}
562
- </SelectContent>
563
- </Select>
564
- <Input
565
- value={query}
566
- onChange={(event) => setQuery(event.target.value)}
567
- placeholder="Search title, preview, messages, tools"
568
- />
569
- <Input
570
- value={ownerEmail}
571
- onChange={(event) => setOwnerEmail(event.target.value)}
572
- placeholder="Owner email"
573
- />
574
- <Button
575
- type="button"
576
- onClick={() =>
577
- setSubmittedSearch({
578
- sourceId,
579
- query: query.trim(),
580
- ownerEmail: ownerEmail.trim(),
581
- })
582
- }
583
- >
584
- <IconSearch size={16} />
585
- Search
586
- </Button>
587
- </div>
878
+ <Tabs
879
+ value={mode}
880
+ onValueChange={(value) => {
881
+ const nextMode = parseMode(value);
882
+ updateRouteState({
883
+ mode: nextMode,
884
+ source:
885
+ nextMode === "threads" && sourceId === "all"
886
+ ? "current"
887
+ : sourceId,
888
+ runId: null,
889
+ threadId: null,
890
+ inspectSource: null,
891
+ });
892
+ }}
893
+ >
894
+ <TabsList>
895
+ <TabsTrigger value="failures">
896
+ {t("dispatch.pages.threadDebugFailedRuns", {
897
+ defaultValue: "Failed runs",
898
+ })}
899
+ </TabsTrigger>
900
+ <TabsTrigger value="threads">
901
+ {t("dispatch.pages.threadDebugThreads", {
902
+ defaultValue: "Threads",
903
+ })}
904
+ </TabsTrigger>
905
+ </TabsList>
588
906
 
589
- <div className="mt-3 grid gap-3 lg:grid-cols-[1fr_auto]">
590
- <Input
591
- value={lookupId}
592
- onChange={(event) => setLookupId(event.target.value)}
593
- placeholder="Paste thread or request/run ID"
594
- className="font-mono"
595
- />
596
- <Button
597
- type="button"
598
- variant="outline"
599
- onClick={() => {
600
- const trimmed = lookupId.trim();
601
- if (!trimmed) return;
602
- setSelected({
603
- sourceId,
604
- lookupId: trimmed,
605
- lookupKind: trimmed.startsWith("run-") ? "run" : "thread",
606
- ownerEmail: ownerEmail.trim() || undefined,
607
- });
608
- }}
609
- >
610
- <IconFileSearch size={16} />
611
- Inspect
612
- </Button>
613
- </div>
907
+ <TabsContent value="failures" className="mt-4 space-y-4">
908
+ <section className="rounded-lg bg-card p-4">
909
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[220px_1fr_180px_150px]">
910
+ <Select
911
+ value={sourceId}
912
+ onValueChange={(value) =>
913
+ updateRouteState({
914
+ source: value,
915
+ runId: null,
916
+ threadId: null,
917
+ inspectSource: null,
918
+ })
919
+ }
920
+ >
921
+ <SelectTrigger>
922
+ <SelectValue
923
+ placeholder={t("dispatch.pages.threadDebugSource", {
924
+ defaultValue: "Source",
925
+ })}
926
+ />
927
+ </SelectTrigger>
928
+ <SelectContent>
929
+ <SelectItem value="all">
930
+ {t("dispatch.pages.threadDebugAllSources", {
931
+ defaultValue: "All sources",
932
+ })}
933
+ </SelectItem>
934
+ {sources.map((source) => (
935
+ <SelectItem key={source.id} value={source.id}>
936
+ {source.label}
937
+ </SelectItem>
938
+ ))}
939
+ </SelectContent>
940
+ </Select>
941
+ <Input
942
+ value={ownerEmail}
943
+ onChange={(event) =>
944
+ updateRouteState({ owner: event.target.value })
945
+ }
946
+ placeholder={t("dispatch.pages.threadDebugOwner", {
947
+ defaultValue: "Owner email",
948
+ })}
949
+ />
950
+ <Select
951
+ value={status}
952
+ onValueChange={(value) =>
953
+ updateRouteState({
954
+ status: parseFailureStatus(value),
955
+ runId: null,
956
+ inspectSource: null,
957
+ })
958
+ }
959
+ >
960
+ <SelectTrigger>
961
+ <SelectValue
962
+ placeholder={t("dispatch.pages.threadDebugStatus", {
963
+ defaultValue: "Status",
964
+ })}
965
+ />
966
+ </SelectTrigger>
967
+ <SelectContent>
968
+ <SelectItem value="all">
969
+ {t("dispatch.pages.threadDebugAllStatuses", {
970
+ defaultValue: "All statuses",
971
+ })}
972
+ </SelectItem>
973
+ <SelectItem value="errored">
974
+ {t("dispatch.pages.threadDebugErrored", {
975
+ defaultValue: "Errored",
976
+ })}
977
+ </SelectItem>
978
+ <SelectItem value="aborted">
979
+ {t("dispatch.pages.threadDebugAborted", {
980
+ defaultValue: "Aborted",
981
+ })}
982
+ </SelectItem>
983
+ <SelectItem value="truncated">
984
+ {t("dispatch.pages.threadDebugTruncated", {
985
+ defaultValue: "Truncated",
986
+ })}
987
+ </SelectItem>
988
+ </SelectContent>
989
+ </Select>
990
+ <Select
991
+ value={range}
992
+ onValueChange={(value) =>
993
+ updateRouteState({
994
+ range: parseFailureRange(value),
995
+ runId: null,
996
+ inspectSource: null,
997
+ })
998
+ }
999
+ >
1000
+ <SelectTrigger>
1001
+ <SelectValue
1002
+ placeholder={t("dispatch.pages.threadDebugRange", {
1003
+ defaultValue: "Time range",
1004
+ })}
1005
+ />
1006
+ </SelectTrigger>
1007
+ <SelectContent>
1008
+ <SelectItem value="24h">
1009
+ {t("dispatch.pages.threadDebugRange24h", {
1010
+ defaultValue: "Last 24 hours",
1011
+ })}
1012
+ </SelectItem>
1013
+ <SelectItem value="7d">
1014
+ {t("dispatch.pages.threadDebugRange7d", {
1015
+ defaultValue: "Last 7 days",
1016
+ })}
1017
+ </SelectItem>
1018
+ <SelectItem value="30d">
1019
+ {t("dispatch.pages.threadDebugRange30d", {
1020
+ defaultValue: "Last 30 days",
1021
+ })}
1022
+ </SelectItem>
1023
+ </SelectContent>
1024
+ </Select>
1025
+ </div>
1026
+ <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1027
+ <span>
1028
+ {failuresData?.count ?? failures.length}{" "}
1029
+ {t("dispatch.pages.threadDebugFailureResults", {
1030
+ defaultValue: "failed runs",
1031
+ })}
1032
+ </span>
1033
+ <span>·</span>
1034
+ <span>
1035
+ {failuresData?.access?.scope ??
1036
+ t("dispatch.pages.threadDebugCurrentScope", {
1037
+ defaultValue: "current scope",
1038
+ })}
1039
+ </span>
1040
+ {failuresData?.partial ? (
1041
+ <Badge variant="outline">
1042
+ {t("dispatch.pages.threadDebugPartialResults", {
1043
+ defaultValue: "Partial results",
1044
+ })}
1045
+ </Badge>
1046
+ ) : null}
1047
+ </div>
1048
+ {unavailableFailureSources.length > 0 ? (
1049
+ <div className="mt-2 flex items-start gap-1.5 text-xs text-muted-foreground">
1050
+ <IconAlertTriangle className="mt-0.5 size-3.5 shrink-0" />
1051
+ <span>
1052
+ {t("dispatch.pages.threadDebugUnavailableSources", {
1053
+ defaultValue: "Unavailable sources:",
1054
+ })}{" "}
1055
+ {unavailableFailureSources
1056
+ .map(
1057
+ ({ source, status: sourceStatus }) =>
1058
+ `${source.label} (${failureSourceStatusLabels[sourceStatus]})`,
1059
+ )
1060
+ .join(", ")}
1061
+ </span>
1062
+ </div>
1063
+ ) : null}
1064
+ </section>
614
1065
 
615
- <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
616
- {sourcesLoading ? <Skeleton className="h-5 w-32" /> : null}
617
- {selectedSource ? <SourceBadge source={selectedSource} /> : null}
618
- {selectedSource?.databaseUrlEnv ? (
619
- <Badge variant="outline" className="font-mono">
620
- {selectedSource.databaseUrlEnv}
621
- </Badge>
622
- ) : null}
623
- {sourcesData?.access ? (
624
- <span>
625
- {sourcesData.access.viewerEmail} ·{" "}
626
- {sourcesData.access.canInspectAll ? "admin scope" : "own scope"}
627
- </span>
1066
+ {failuresError ? (
1067
+ <ActionQueryError
1068
+ error={failuresError}
1069
+ onRetry={() => void refetchFailures()}
1070
+ />
628
1071
  ) : null}
629
- </div>
630
- </section>
631
1072
 
632
- {searchError ? (
633
- <ActionQueryError
634
- error={searchError}
635
- onRetry={() => void refetchSearch()}
636
- />
637
- ) : null}
638
-
639
- <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
640
- <section className="min-h-[520px] rounded-lg bg-card">
641
- <div className="flex items-center justify-between border-b px-4 py-3">
642
- <div>
643
- <div className="text-sm font-semibold text-foreground">
644
- Threads
1073
+ <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
1074
+ <section className="min-h-[520px] rounded-lg bg-card">
1075
+ <div className="flex items-center justify-between border-b px-4 py-3">
1076
+ <div className="text-sm font-semibold text-foreground">
1077
+ {t("dispatch.pages.threadDebugFailedRuns", {
1078
+ defaultValue: "Failed runs",
1079
+ })}
1080
+ </div>
1081
+ <Button
1082
+ type="button"
1083
+ variant="ghost"
1084
+ size="icon"
1085
+ onClick={() => void refetchFailures()}
1086
+ aria-label={t("dispatch.pages.threadDebugRefreshFailures", {
1087
+ defaultValue: "Refresh failed runs",
1088
+ })}
1089
+ >
1090
+ <IconRefresh className="size-4" />
1091
+ </Button>
645
1092
  </div>
646
- <div className="text-xs text-muted-foreground">
647
- {searchData?.count ?? 0} results ·{" "}
648
- {searchData?.access?.scope ?? "current scope"}
1093
+ <div className="max-h-[760px] space-y-2 overflow-auto p-3">
1094
+ {failuresLoading ? (
1095
+ <>
1096
+ <Skeleton className="h-32 w-full rounded-lg" />
1097
+ <Skeleton className="h-32 w-full rounded-lg" />
1098
+ <Skeleton className="h-32 w-full rounded-lg" />
1099
+ </>
1100
+ ) : null}
1101
+ {!failuresLoading && failures.length === 0 ? (
1102
+ <div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm text-muted-foreground">
1103
+ <IconDatabase className="mb-2 size-5" />
1104
+ {t("dispatch.pages.threadDebugNoFailures", {
1105
+ defaultValue: "No failed runs found.",
1106
+ })}
1107
+ </div>
1108
+ ) : null}
1109
+ {failures.map((failure) => (
1110
+ <FailureCard
1111
+ key={`${failureSourceId(failure)}:${failure.id}`}
1112
+ failure={failure}
1113
+ selected={
1114
+ runId === failure.id &&
1115
+ detailSourceId === failureSourceId(failure)
1116
+ }
1117
+ onSelect={() =>
1118
+ updateRouteState({
1119
+ inspectSource: failureSourceId(failure),
1120
+ runId: failure.id,
1121
+ threadId: null,
1122
+ })
1123
+ }
1124
+ />
1125
+ ))}
649
1126
  </div>
650
- </div>
651
- <Button
652
- type="button"
653
- variant="ghost"
654
- size="icon"
655
- onClick={() => refetchSearch()}
656
- aria-label="Refresh threads"
657
- >
658
- <IconRefresh size={16} />
659
- </Button>
1127
+ </section>
1128
+ {detailPane}
660
1129
  </div>
661
- <div className="max-h-[760px] space-y-2 overflow-auto p-3">
662
- {searchLoading ? (
663
- <>
664
- <Skeleton className="h-28 w-full rounded-lg" />
665
- <Skeleton className="h-28 w-full rounded-lg" />
666
- <Skeleton className="h-28 w-full rounded-lg" />
667
- </>
668
- ) : null}
669
- {!searchLoading && searchThreads.length === 0 ? (
670
- <div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm text-muted-foreground">
671
- <IconDatabase className="mb-2 h-5 w-5" />
672
- No threads found.
673
- </div>
674
- ) : null}
675
- {searchThreads.map((result) => (
676
- <ResultCard
677
- key={result.id}
678
- result={result}
679
- selected={
680
- selected?.lookupKind === "thread" &&
681
- selected.lookupId === result.id
682
- }
683
- onSelect={() =>
684
- setSelected({
685
- sourceId: submittedSearch.sourceId,
686
- lookupId: result.id,
687
- lookupKind: "thread",
688
- ownerEmail: submittedSearch.ownerEmail || undefined,
1130
+ </TabsContent>
1131
+
1132
+ <TabsContent value="threads" className="mt-4 space-y-4">
1133
+ <section className="rounded-lg bg-card p-4">
1134
+ <div className="grid gap-3 lg:grid-cols-[220px_1fr_260px_auto]">
1135
+ <Select
1136
+ value={threadSourceId}
1137
+ onValueChange={(value) =>
1138
+ updateRouteState({
1139
+ source: value,
1140
+ runId: null,
1141
+ threadId: null,
1142
+ inspectSource: null,
689
1143
  })
690
1144
  }
1145
+ >
1146
+ <SelectTrigger>
1147
+ <SelectValue
1148
+ placeholder={t("dispatch.pages.threadDebugSource", {
1149
+ defaultValue: "Source",
1150
+ })}
1151
+ />
1152
+ </SelectTrigger>
1153
+ <SelectContent>
1154
+ {sources.map((source) => (
1155
+ <SelectItem key={source.id} value={source.id}>
1156
+ {source.label}
1157
+ </SelectItem>
1158
+ ))}
1159
+ {sources.length === 0 ? (
1160
+ <SelectItem value="current">
1161
+ {t("dispatch.pages.threadDebugCurrentDatabase", {
1162
+ defaultValue: "Current Dispatch DB",
1163
+ })}
1164
+ </SelectItem>
1165
+ ) : null}
1166
+ </SelectContent>
1167
+ </Select>
1168
+ <Input
1169
+ value={query}
1170
+ onChange={(event) =>
1171
+ updateRouteState({ query: event.target.value })
1172
+ }
1173
+ placeholder={t(
1174
+ "dispatch.pages.threadDebugSearchPlaceholder",
1175
+ {
1176
+ defaultValue: "Search title, preview, messages, tools",
1177
+ },
1178
+ )}
691
1179
  />
692
- ))}
693
- </div>
694
- </section>
1180
+ <Input
1181
+ value={ownerEmail}
1182
+ onChange={(event) =>
1183
+ updateRouteState({ owner: event.target.value })
1184
+ }
1185
+ placeholder={t("dispatch.pages.threadDebugOwner", {
1186
+ defaultValue: "Owner email",
1187
+ })}
1188
+ />
1189
+ <Button type="button" onClick={() => void refetchSearch()}>
1190
+ <IconSearch className="size-4" />
1191
+ {t("dispatch.pages.threadDebugSearch", {
1192
+ defaultValue: "Search",
1193
+ })}
1194
+ </Button>
1195
+ </div>
1196
+
1197
+ <div className="mt-3 grid gap-3 lg:grid-cols-[1fr_auto]">
1198
+ <Input
1199
+ value={lookupId}
1200
+ onChange={(event) => setLookupId(event.target.value)}
1201
+ placeholder={t(
1202
+ "dispatch.pages.threadDebugLookupPlaceholder",
1203
+ {
1204
+ defaultValue: "Paste thread or request/run ID",
1205
+ },
1206
+ )}
1207
+ className="font-mono"
1208
+ />
1209
+ <Button
1210
+ type="button"
1211
+ variant="outline"
1212
+ onClick={() => {
1213
+ const trimmed = lookupId.trim();
1214
+ if (!trimmed) return;
1215
+ updateRouteState(
1216
+ trimmed.startsWith("run-")
1217
+ ? {
1218
+ runId: trimmed,
1219
+ threadId: null,
1220
+ inspectSource: null,
1221
+ }
1222
+ : {
1223
+ threadId: trimmed,
1224
+ runId: null,
1225
+ inspectSource: null,
1226
+ },
1227
+ );
1228
+ }}
1229
+ >
1230
+ <IconFileSearch className="size-4" />
1231
+ {t("dispatch.pages.threadDebugInspect", {
1232
+ defaultValue: "Inspect",
1233
+ })}
1234
+ </Button>
1235
+ </div>
1236
+
1237
+ <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1238
+ {sourcesLoading ? <Skeleton className="h-5 w-32" /> : null}
1239
+ {selectedSource ? (
1240
+ <SourceBadge source={selectedSource} />
1241
+ ) : null}
1242
+ {selectedSource?.databaseUrlEnv ? (
1243
+ <Badge variant="outline" className="font-mono">
1244
+ {selectedSource.databaseUrlEnv}
1245
+ </Badge>
1246
+ ) : null}
1247
+ {sourcesData?.access ? (
1248
+ <span>
1249
+ {sourcesData.access.viewerEmail} ·{" "}
1250
+ {sourcesData.access.canInspectAll
1251
+ ? t("dispatch.pages.threadDebugAdminScope", {
1252
+ defaultValue: "admin scope",
1253
+ })
1254
+ : t("dispatch.pages.threadDebugOwnScope", {
1255
+ defaultValue: "own scope",
1256
+ })}
1257
+ </span>
1258
+ ) : null}
1259
+ </div>
1260
+ </section>
695
1261
 
696
- <section className="min-w-0">
697
- {detailError ? (
1262
+ {searchError ? (
698
1263
  <ActionQueryError
699
- error={detailError}
700
- onRetry={() => void refetchDetail()}
1264
+ error={searchError}
1265
+ onRetry={() => void refetchSearch()}
701
1266
  />
702
1267
  ) : null}
703
- {detailLoading ? (
704
- <div className="rounded-lg bg-card p-4">
705
- <Skeleton className="h-6 w-72" />
706
- <Skeleton className="mt-3 h-4 w-96" />
707
- <Skeleton className="mt-6 h-[520px] w-full" />
708
- </div>
709
- ) : detail ? (
710
- <ThreadDetail detail={detail} />
711
- ) : (
712
- <div className="flex min-h-[520px] flex-col items-center justify-center rounded-lg border border-dashed bg-card px-4 text-center text-sm text-muted-foreground">
713
- <IconFileSearch className="mb-2 h-5 w-5" />
714
- Select or inspect a thread or request/run ID.
715
- </div>
716
- )}
717
- </section>
718
- </div>
1268
+
1269
+ <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
1270
+ <section className="min-h-[520px] rounded-lg bg-card">
1271
+ <div className="flex items-center justify-between border-b px-4 py-3">
1272
+ <div>
1273
+ <div className="text-sm font-semibold text-foreground">
1274
+ {t("dispatch.pages.threadDebugThreads", {
1275
+ defaultValue: "Threads",
1276
+ })}
1277
+ </div>
1278
+ <div className="text-xs text-muted-foreground">
1279
+ {searchData?.count ?? 0}{" "}
1280
+ {t("dispatch.pages.threadDebugResults", {
1281
+ defaultValue: "results",
1282
+ })}{" "}
1283
+ ·{" "}
1284
+ {searchData?.access?.scope ??
1285
+ t("dispatch.pages.threadDebugCurrentScope", {
1286
+ defaultValue: "current scope",
1287
+ })}
1288
+ </div>
1289
+ </div>
1290
+ <Button
1291
+ type="button"
1292
+ variant="ghost"
1293
+ size="icon"
1294
+ onClick={() => void refetchSearch()}
1295
+ aria-label={t("dispatch.pages.threadDebugRefreshThreads", {
1296
+ defaultValue: "Refresh threads",
1297
+ })}
1298
+ >
1299
+ <IconRefresh className="size-4" />
1300
+ </Button>
1301
+ </div>
1302
+ <div className="max-h-[760px] space-y-2 overflow-auto p-3">
1303
+ {searchLoading ? (
1304
+ <>
1305
+ <Skeleton className="h-28 w-full rounded-lg" />
1306
+ <Skeleton className="h-28 w-full rounded-lg" />
1307
+ <Skeleton className="h-28 w-full rounded-lg" />
1308
+ </>
1309
+ ) : null}
1310
+ {!searchLoading && searchThreads.length === 0 ? (
1311
+ <div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm text-muted-foreground">
1312
+ <IconDatabase className="mb-2 size-5" />
1313
+ {t("dispatch.pages.threadDebugNoThreads", {
1314
+ defaultValue: "No threads found.",
1315
+ })}
1316
+ </div>
1317
+ ) : null}
1318
+ {searchThreads.map((result) => (
1319
+ <ResultCard
1320
+ key={result.id}
1321
+ result={result}
1322
+ selected={threadId === result.id}
1323
+ onSelect={() =>
1324
+ updateRouteState({
1325
+ threadId: result.id,
1326
+ runId: null,
1327
+ inspectSource: null,
1328
+ })
1329
+ }
1330
+ />
1331
+ ))}
1332
+ </div>
1333
+ </section>
1334
+ {detailPane}
1335
+ </div>
1336
+ </TabsContent>
1337
+ </Tabs>
719
1338
  </div>
720
1339
  </DispatchShell>
721
1340
  );