diff --git a/.changeset/thread-debug-operators.md b/.changeset/thread-debug-operators.md new file mode 100644 index 0000000000..606f2d404c --- /dev/null +++ b/.changeset/thread-debug-operators.md @@ -0,0 +1,5 @@ +--- +"@agent-native/dispatch": patch +--- + +Allow approved read-only Thread Debug operators to inspect organization-owned service-principal traces and keep failed lookups distinct from successful empty results. diff --git a/packages/dispatch/src/actions/get-agent-thread-debug.ts b/packages/dispatch/src/actions/get-agent-thread-debug.ts index d7bb4becd5..10e64d7194 100644 --- a/packages/dispatch/src/actions/get-agent-thread-debug.ts +++ b/packages/dispatch/src/actions/get-agent-thread-debug.ts @@ -27,7 +27,9 @@ export default defineAction({ ownerEmail: z .string() .optional() - .describe("Optional owner email scope for admin cross-user lookups."), + .describe( + "Optional owner email filter inside the organization-visible scope available to approved Thread Debug operators and admins.", + ), maxRuns: z.coerce.number().int().min(1).max(50).default(20), maxEvents: z.coerce.number().int().min(1).max(2000).default(600), maxTraceSpans: z.coerce.number().int().min(1).max(2000).default(500), diff --git a/packages/dispatch/src/actions/list-agent-run-failures.ts b/packages/dispatch/src/actions/list-agent-run-failures.ts index 2c8d7b6cc1..9aa761242b 100644 --- a/packages/dispatch/src/actions/list-agent-run-failures.ts +++ b/packages/dispatch/src/actions/list-agent-run-failures.ts @@ -17,7 +17,7 @@ export default defineAction({ .string() .optional() .describe( - "Optional owner email filter. Organization admins may only select members of their current organization.", + "Optional owner email filter inside the caller's permitted scope. Approved Thread Debug operators and admins remain limited to their current organization.", ), status: z .enum(["all", "errored", "aborted", "truncated"]) diff --git a/packages/dispatch/src/actions/search-agent-threads.ts b/packages/dispatch/src/actions/search-agent-threads.ts index c45821238c..38b75bc0cb 100644 --- a/packages/dispatch/src/actions/search-agent-threads.ts +++ b/packages/dispatch/src/actions/search-agent-threads.ts @@ -5,7 +5,7 @@ import { searchAgentThreads } from "../server/lib/thread-debug-store.js"; export default defineAction({ description: - "Search agent chat threads by title, preview, full persisted thread content, or an exact request/run ID. Non-admins are limited to their own current Dispatch DB threads.", + "Search agent chat threads by title, preview, full persisted thread content, or an exact request/run ID. Approved read-only Thread Debug operators and organization admins may inspect organization-owned threads across connected sources; other callers are limited to their own current Dispatch threads.", schema: z.object({ sourceId: z .string() @@ -21,7 +21,7 @@ export default defineAction({ .string() .optional() .describe( - "Optional owner email filter. Admins may pass '*' or omit to search the admin-visible scope.", + "Optional owner email filter inside the caller's permitted scope. Approved Thread Debug operators and admins may pass '*' or omit it to search their organization-visible scope.", ), limit: z.coerce.number().int().min(1).max(100).default(25), }), diff --git a/packages/dispatch/src/routes/pages/thread-debug.spec.tsx b/packages/dispatch/src/routes/pages/thread-debug.spec.tsx index af7913b50a..d915536db6 100644 --- a/packages/dispatch/src/routes/pages/thread-debug.spec.tsx +++ b/packages/dispatch/src/routes/pages/thread-debug.spec.tsx @@ -12,6 +12,9 @@ const queryState = vi.hoisted(() => ({ params: Record; enabled: boolean; }>, + errorNames: new Set(), + emptyNames: new Set(), + unavailableFailures: false, })); const failedRun = { @@ -85,8 +88,10 @@ vi.mock("@agent-native/core/client/hooks", () => ({ queryState.calls.push({ name, params, enabled }); const base = { isLoading: false, - isError: false, - error: null, + isError: queryState.errorNames.has(name), + error: queryState.errorNames.has(name) + ? new Error("Thread Debug request failed") + : null, refetch: vi.fn(), }; if (name === "list-agent-thread-sources") { @@ -98,6 +103,7 @@ vi.mock("@agent-native/core/client/hooks", () => ({ orgId: "org-1", role: "admin", envAdmin: false, + threadDebugOperator: false, canInspectAll: true, memberCount: 1, }, @@ -127,30 +133,87 @@ vi.mock("@agent-native/core/client/hooks", () => ({ }; } if (name === "list-agent-run-failures") { + if (queryState.errorNames.has(name)) { + return { ...base, data: undefined }; + } + const failures = + queryState.emptyNames.has(name) || queryState.unavailableFailures + ? [] + : [failedRun]; return { ...base, data: enabled ? { - failures: [failedRun], - count: 1, - partial: true, + failures, + count: failures.length, + partial: + queryState.unavailableFailures || + !queryState.emptyNames.has(name), access: { viewerEmail: "ops@example.com", scope: "current organization", canInspectAll: true, }, - sources: [ - { - source: failedRun.source, - status: "ok", - failureCount: 1, - }, - { - source: { id: "clips", label: "Clips" }, - status: "unavailable", - failureCount: 0, - }, - ], + sources: queryState.unavailableFailures + ? queryState.emptyNames.has(name) + ? [ + { + source: failedRun.source, + status: "ok", + failureCount: 0, + }, + { + source: { id: "clips", label: "Clips" }, + status: "unavailable", + failureCount: 0, + }, + ] + : [ + { + source: failedRun.source, + status: "unavailable", + failureCount: 0, + }, + ] + : queryState.emptyNames.has(name) + ? [ + { + source: failedRun.source, + status: "ok", + failureCount: 0, + }, + ] + : [ + { + source: failedRun.source, + status: "ok", + failureCount: failures.length, + }, + { + source: { id: "clips", label: "Clips" }, + status: "unavailable", + failureCount: 0, + }, + ], + } + : undefined, + }; + } + if (name === "search-agent-threads") { + if (queryState.errorNames.has(name)) { + return { ...base, data: undefined }; + } + return { + ...base, + data: enabled + ? { + count: 0, + threads: [], + access: { + scope: "current organization", + canInspectAll: true, + }, + source: { id: "mail", label: "Mail" }, } : undefined, }; @@ -192,6 +255,9 @@ describe("ThreadDebugRoute", () => { beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); queryState.calls = []; + queryState.errorNames.clear(); + queryState.emptyNames.clear(); + queryState.unavailableFailures = false; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -269,4 +335,105 @@ describe("ThreadDebugRoute", () => { lookbackHours: 168, }); }); + + it("does not render a failed run request as a successful empty result", async () => { + queryState.errorNames.add("list-agent-run-failures"); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("dispatch.pages.dataLoadFailed"); + expect(container.textContent).not.toContain("0 failed runs"); + expect(container.textContent).not.toContain("No failed runs found."); + }); + + it("does not render a failed thread search as a successful empty result", async () => { + queryState.errorNames.add("search-agent-threads"); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("dispatch.pages.dataLoadFailed"); + expect(container.textContent).not.toContain("0 results"); + expect(container.textContent).not.toContain("No threads found."); + }); + + it("renders a genuine empty failed-run request as zero results", async () => { + queryState.emptyNames.add("list-agent-run-failures"); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("0 failed runs"); + expect(container.textContent).toContain("No failed runs found."); + }); + + it("does not render an unavailable failure source as zero results", async () => { + queryState.unavailableFailures = true; + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("Mail (unavailable)"); + expect(container.textContent).not.toContain("0 failed runs"); + expect(container.textContent).not.toContain("No failed runs found."); + }); + + it("does not render mixed empty and unavailable sources as a genuine zero", async () => { + queryState.emptyNames.add("list-agent-run-failures"); + queryState.unavailableFailures = true; + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("Clips (unavailable)"); + expect(container.textContent).not.toContain("0 failed runs"); + expect(container.textContent).not.toContain("No failed runs found."); + }); + + it("renders a genuine empty thread search as zero results", async () => { + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.textContent).toContain("0 results"); + expect(container.textContent).toContain("No threads found."); + }); }); diff --git a/packages/dispatch/src/routes/pages/thread-debug.tsx b/packages/dispatch/src/routes/pages/thread-debug.tsx index 0519a18f89..cd8441d238 100644 --- a/packages/dispatch/src/routes/pages/thread-debug.tsx +++ b/packages/dispatch/src/routes/pages/thread-debug.tsx @@ -736,6 +736,7 @@ export default function ThreadDebugRoute() { orgId: string | null; role: string | null; envAdmin: boolean; + threadDebugOperator: boolean; canInspectAll: boolean; memberCount: number; }; @@ -768,6 +769,9 @@ export default function ThreadDebugRoute() { const unavailableFailureSources = (failuresData?.sources ?? []).filter( (source) => source.status !== "ok", ); + const allFailureSourcesSucceeded = + (failuresData?.sources.length ?? 0) > 0 && + failuresData?.sources.every((source) => source.status === "ok"); const failureSourceStatusLabels = { ok: "ok", disconnected: t("dispatch.pages.threadDebugDisconnected", { @@ -1024,19 +1028,20 @@ export default function ThreadDebugRoute() {
- - {failuresData?.count ?? failures.length}{" "} - {t("dispatch.pages.threadDebugFailureResults", { - defaultValue: "failed runs", - })} - - · - - {failuresData?.access?.scope ?? - t("dispatch.pages.threadDebugCurrentScope", { - defaultValue: "current scope", - })} - + {!failuresError && + failuresData && + allFailureSourcesSucceeded ? ( + <> + + {failuresData.count}{" "} + {t("dispatch.pages.threadDebugFailureResults", { + defaultValue: "failed runs", + })} + + · + {failuresData.access.scope} + + ) : null} {failuresData?.partial ? ( {t("dispatch.pages.threadDebugPartialResults", { @@ -1098,7 +1103,11 @@ export default function ThreadDebugRoute() { ) : null} - {!failuresLoading && failures.length === 0 ? ( + {!failuresLoading && + !failuresError && + failuresData && + allFailureSourcesSucceeded && + failures.length === 0 ? (
{t("dispatch.pages.threadDebugNoFailures", { @@ -1247,13 +1256,17 @@ export default function ThreadDebugRoute() { {sourcesData?.access ? ( {sourcesData.access.viewerEmail} ·{" "} - {sourcesData.access.canInspectAll - ? t("dispatch.pages.threadDebugAdminScope", { - defaultValue: "admin scope", + {sourcesData.access.threadDebugOperator + ? t("dispatch.pages.threadDebugCurrentScope", { + defaultValue: "organization scope", }) - : t("dispatch.pages.threadDebugOwnScope", { - defaultValue: "own scope", - })} + : sourcesData.access.canInspectAll + ? t("dispatch.pages.threadDebugAdminScope", { + defaultValue: "admin scope", + }) + : t("dispatch.pages.threadDebugOwnScope", { + defaultValue: "own scope", + })} ) : null}
@@ -1276,15 +1289,15 @@ export default function ThreadDebugRoute() { })}
- {searchData?.count ?? 0}{" "} - {t("dispatch.pages.threadDebugResults", { - defaultValue: "results", - })}{" "} - ·{" "} - {searchData?.access?.scope ?? - t("dispatch.pages.threadDebugCurrentScope", { - defaultValue: "current scope", - })} + {!searchError && searchData ? ( + <> + {searchData.count}{" "} + {t("dispatch.pages.threadDebugResults", { + defaultValue: "results", + })}{" "} + · {searchData.access.scope} + + ) : null}