Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thread-debug-operators.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/dispatch/src/actions/get-agent-thread-debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion packages/dispatch/src/actions/list-agent-run-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
4 changes: 2 additions & 2 deletions packages/dispatch/src/actions/search-agent-threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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),
}),
Expand Down
201 changes: 184 additions & 17 deletions packages/dispatch/src/routes/pages/thread-debug.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const queryState = vi.hoisted(() => ({
params: Record<string, unknown>;
enabled: boolean;
}>,
errorNames: new Set<string>(),
emptyNames: new Set<string>(),
unavailableFailures: false,
}));

const failedRun = {
Expand Down Expand Up @@ -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") {
Expand All @@ -98,6 +103,7 @@ vi.mock("@agent-native/core/client/hooks", () => ({
orgId: "org-1",
role: "admin",
envAdmin: false,
threadDebugOperator: false,
canInspectAll: true,
memberCount: 1,
},
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={["/thread-debug"]}>
<ThreadDebugRoute />
</MemoryRouter>,
);
});

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(
<MemoryRouter
initialEntries={[
"/thread-debug?mode=threads&source=mail&query=AN-SLACK-CANARY-EXAMPLE",
]}
>
<ThreadDebugRoute />
</MemoryRouter>,
);
});

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(
<MemoryRouter initialEntries={["/thread-debug"]}>
<ThreadDebugRoute />
</MemoryRouter>,
);
});

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(
<MemoryRouter initialEntries={["/thread-debug?source=mail"]}>
<ThreadDebugRoute />
</MemoryRouter>,
);
});

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(
<MemoryRouter initialEntries={["/thread-debug"]}>
<ThreadDebugRoute />
</MemoryRouter>,
);
});

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(
<MemoryRouter
initialEntries={[
"/thread-debug?mode=threads&source=mail&query=AN-SLACK-CANARY-EXAMPLE",
]}
>
<ThreadDebugRoute />
</MemoryRouter>,
);
});

expect(container.textContent).toContain("0 results");
expect(container.textContent).toContain("No threads found.");
});
});
Loading
Loading