@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.
- package/dist/actions/get-agent-thread-debug.d.ts +9 -0
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-agent-run-failures.d.ts +61 -0
- package/dist/actions/list-agent-run-failures.d.ts.map +1 -0
- package/dist/actions/list-agent-run-failures.js +38 -0
- package/dist/actions/list-agent-run-failures.js.map +1 -0
- package/dist/actions/provider-api-register.d.ts +12 -12
- package/dist/actions/upsert-destination.d.ts +12 -12
- package/dist/actions/view-screen.js +32 -5
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/hooks/use-navigation-state.d.ts +6 -0
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +30 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/routes/pages/thread-debug.d.ts.map +1 -1
- package/dist/routes/pages/thread-debug.js +291 -77
- package/dist/routes/pages/thread-debug.js.map +1 -1
- package/dist/server/lib/thread-debug-store.d.ts +72 -0
- package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
- package/dist/server/lib/thread-debug-store.js +215 -19
- package/dist/server/lib/thread-debug-store.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +1 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/index.ts +2 -0
- package/src/actions/list-agent-run-failures.ts +44 -0
- package/src/actions/view-screen.spec.ts +91 -0
- package/src/actions/view-screen.ts +34 -4
- package/src/hooks/use-navigation-state.spec.ts +45 -0
- package/src/hooks/use-navigation-state.ts +28 -0
- package/src/routes/pages/thread-debug.spec.tsx +272 -0
- package/src/routes/pages/thread-debug.tsx +843 -224
- package/src/server/lib/thread-debug-store.spec.ts +292 -16
- package/src/server/lib/thread-debug-store.ts +318 -22
- package/src/server/plugins/agent-chat.spec.ts +30 -0
- package/src/server/plugins/agent-chat.ts +1 -0
|
@@ -1,21 +1,26 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
|
-
|
|
4
|
+
currentExecute: vi.fn(),
|
|
5
|
+
createDbExec: vi.fn(),
|
|
6
|
+
orgId: null as string | null,
|
|
7
|
+
ownerEmail: "owner@example.com",
|
|
5
8
|
}));
|
|
6
9
|
|
|
7
10
|
vi.mock("@agent-native/core/db", () => ({
|
|
8
|
-
createDbExec:
|
|
9
|
-
getDbExec: () => ({ execute: mocks.
|
|
11
|
+
createDbExec: mocks.createDbExec,
|
|
12
|
+
getDbExec: () => ({ execute: mocks.currentExecute }),
|
|
10
13
|
}));
|
|
11
14
|
|
|
12
15
|
vi.mock("./dispatch-store.js", () => ({
|
|
13
|
-
currentOrgId: () =>
|
|
14
|
-
currentOwnerEmail: () =>
|
|
16
|
+
currentOrgId: () => mocks.orgId,
|
|
17
|
+
currentOwnerEmail: () => mocks.ownerEmail,
|
|
15
18
|
}));
|
|
16
19
|
|
|
17
20
|
import {
|
|
18
21
|
getAgentThreadDebug,
|
|
22
|
+
listAgentRunFailures,
|
|
23
|
+
listThreadDebugSources,
|
|
19
24
|
searchAgentThreads,
|
|
20
25
|
} from "./thread-debug-store.js";
|
|
21
26
|
|
|
@@ -33,18 +38,32 @@ const thread = {
|
|
|
33
38
|
const run = {
|
|
34
39
|
id: "run-prod-1",
|
|
35
40
|
thread_id: "thread-1",
|
|
36
|
-
|
|
41
|
+
turn_id: "turn-1",
|
|
42
|
+
status: "errored",
|
|
43
|
+
abort_reason: null,
|
|
37
44
|
started_at: 1,
|
|
38
|
-
completed_at:
|
|
39
|
-
heartbeat_at:
|
|
45
|
+
completed_at: 12,
|
|
46
|
+
heartbeat_at: 10,
|
|
47
|
+
last_progress_at: 9,
|
|
48
|
+
error_code: "provider_timeout",
|
|
49
|
+
error_detail: "The provider did not respond.",
|
|
50
|
+
terminal_reason: "provider_timeout",
|
|
51
|
+
dispatch_mode: "background-processing",
|
|
52
|
+
worker_stage: "model",
|
|
53
|
+
diag_stage: '{"stage":"model"}',
|
|
54
|
+
peak_rss_mb: 321,
|
|
40
55
|
};
|
|
41
56
|
|
|
42
|
-
function
|
|
57
|
+
function rowsForThreadLookup(sql: string, args: unknown[]) {
|
|
43
58
|
if (sql.includes("FROM org_members")) return [];
|
|
44
59
|
if (sql.includes("FROM agent_runs") && sql.includes("WHERE id = ?")) {
|
|
45
60
|
return [run];
|
|
46
61
|
}
|
|
47
|
-
if (
|
|
62
|
+
if (
|
|
63
|
+
sql.includes("FROM agent_runs r") &&
|
|
64
|
+
sql.includes("r.thread_id = ?") &&
|
|
65
|
+
!sql.includes("JOIN chat_threads")
|
|
66
|
+
) {
|
|
48
67
|
return [run];
|
|
49
68
|
}
|
|
50
69
|
if (sql.includes("FROM chat_threads")) {
|
|
@@ -55,13 +74,42 @@ function rowsForQuery(sql: string, args: unknown[]) {
|
|
|
55
74
|
return [];
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
|
|
77
|
+
function failureRow(
|
|
78
|
+
id: string,
|
|
79
|
+
completedAt: number,
|
|
80
|
+
overrides: Record<string, unknown> = {},
|
|
81
|
+
) {
|
|
82
|
+
return {
|
|
83
|
+
...run,
|
|
84
|
+
id,
|
|
85
|
+
completed_at: completedAt,
|
|
86
|
+
debug_owner_email: "owner@example.com",
|
|
87
|
+
debug_thread_title: `Thread for ${id}`,
|
|
88
|
+
debug_thread_preview: "Preview",
|
|
89
|
+
debug_terminal_event_data: JSON.stringify({
|
|
90
|
+
type: "error",
|
|
91
|
+
errorCode: "provider_timeout",
|
|
92
|
+
}),
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe("thread-debug-store", () => {
|
|
59
98
|
beforeEach(() => {
|
|
60
|
-
mocks.
|
|
61
|
-
|
|
99
|
+
mocks.orgId = null;
|
|
100
|
+
mocks.ownerEmail = "owner@example.com";
|
|
101
|
+
mocks.currentExecute.mockReset();
|
|
102
|
+
mocks.createDbExec.mockReset();
|
|
103
|
+
mocks.currentExecute.mockImplementation(async ({ sql, args }) => ({
|
|
104
|
+
rows: rowsForThreadLookup(sql, args),
|
|
62
105
|
}));
|
|
63
106
|
});
|
|
64
107
|
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
vi.unstubAllEnvs();
|
|
110
|
+
vi.useRealTimers();
|
|
111
|
+
});
|
|
112
|
+
|
|
65
113
|
it("finds a thread when search input is an exact run id", async () => {
|
|
66
114
|
const result = await searchAgentThreads({ query: run.id });
|
|
67
115
|
|
|
@@ -69,7 +117,7 @@ describe("thread-debug-store request/run lookup", () => {
|
|
|
69
117
|
expect(result.threads[0]?.id).toBe(thread.id);
|
|
70
118
|
});
|
|
71
119
|
|
|
72
|
-
it("resolves a run id
|
|
120
|
+
it("resolves a run id and returns rich run diagnostics", async () => {
|
|
73
121
|
const result = await getAgentThreadDebug({ runId: run.id });
|
|
74
122
|
|
|
75
123
|
expect(result.lookup).toEqual({
|
|
@@ -77,6 +125,234 @@ describe("thread-debug-store request/run lookup", () => {
|
|
|
77
125
|
threadId: thread.id,
|
|
78
126
|
runId: run.id,
|
|
79
127
|
});
|
|
80
|
-
expect(result.
|
|
128
|
+
expect(result.runs[0]).toMatchObject({
|
|
129
|
+
id: run.id,
|
|
130
|
+
turnId: "turn-1",
|
|
131
|
+
status: "errored",
|
|
132
|
+
errorCode: "provider_timeout",
|
|
133
|
+
errorDetail: "The provider did not respond.",
|
|
134
|
+
terminalReason: "provider_timeout",
|
|
135
|
+
lastProgressAt: 9,
|
|
136
|
+
dispatchMode: "background-processing",
|
|
137
|
+
workerStage: "model",
|
|
138
|
+
diagStage: '{"stage":"model"}',
|
|
139
|
+
peakRssMb: 321,
|
|
140
|
+
});
|
|
141
|
+
expect(
|
|
142
|
+
mocks.currentExecute.mock.calls.some(([request]) =>
|
|
143
|
+
request.sql.includes("SELECT r.*"),
|
|
144
|
+
),
|
|
145
|
+
).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps legacy failure rows readable when additive diagnostics are absent", async () => {
|
|
149
|
+
vi.useFakeTimers();
|
|
150
|
+
vi.setSystemTime(new Date("2026-07-30T12:00:00Z"));
|
|
151
|
+
mocks.currentExecute.mockImplementation(async ({ sql }) => ({
|
|
152
|
+
rows: sql.includes("JOIN chat_threads")
|
|
153
|
+
? [
|
|
154
|
+
failureRow("run-legacy", Date.now() - 1_000, {
|
|
155
|
+
turn_id: undefined,
|
|
156
|
+
error_code: undefined,
|
|
157
|
+
error_detail: undefined,
|
|
158
|
+
terminal_reason: undefined,
|
|
159
|
+
dispatch_mode: undefined,
|
|
160
|
+
worker_stage: undefined,
|
|
161
|
+
diag_stage: undefined,
|
|
162
|
+
peak_rss_mb: undefined,
|
|
163
|
+
debug_terminal_event_data: null,
|
|
164
|
+
}),
|
|
165
|
+
]
|
|
166
|
+
: [],
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
const result = await listAgentRunFailures({ sourceId: "current" });
|
|
170
|
+
|
|
171
|
+
expect(result.failures[0]).toMatchObject({
|
|
172
|
+
id: "run-legacy",
|
|
173
|
+
turnId: null,
|
|
174
|
+
errorCode: null,
|
|
175
|
+
errorDetail: null,
|
|
176
|
+
terminalReason: null,
|
|
177
|
+
dispatchMode: null,
|
|
178
|
+
workerStage: null,
|
|
179
|
+
diagStage: null,
|
|
180
|
+
peakRssMb: null,
|
|
181
|
+
terminalEvent: null,
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("merges all admin-visible sources, sorts globally, limits, and preserves partial health", async () => {
|
|
186
|
+
vi.stubEnv("DISPATCH_ADMIN_EMAILS", "owner@example.com");
|
|
187
|
+
vi.stubEnv("REMOTE_A_DATABASE_URL", "libsql://remote-a");
|
|
188
|
+
vi.stubEnv("REMOTE_B_DATABASE_URL", "libsql://remote-b");
|
|
189
|
+
vi.stubEnv("REMOTE_C_DATABASE_URL", "libsql://remote-c");
|
|
190
|
+
|
|
191
|
+
mocks.currentExecute.mockImplementation(async ({ sql }) => ({
|
|
192
|
+
rows: sql.includes("JOIN chat_threads")
|
|
193
|
+
? [failureRow("run-current", 100)]
|
|
194
|
+
: [],
|
|
195
|
+
}));
|
|
196
|
+
mocks.createDbExec.mockImplementation(async ({ url }) => ({
|
|
197
|
+
execute: async ({ sql }: { sql: string }) => {
|
|
198
|
+
if (url === "libsql://remote-a") {
|
|
199
|
+
return {
|
|
200
|
+
rows: sql.includes("JOIN chat_threads")
|
|
201
|
+
? [failureRow("run-a-new", 300), failureRow("run-a-old", 200)]
|
|
202
|
+
: [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (url === "libsql://remote-b") {
|
|
206
|
+
throw new Error("SQLITE_ERROR: no such table: agent_runs");
|
|
207
|
+
}
|
|
208
|
+
throw new Error("connect ECONNREFUSED 127.0.0.1");
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
const result = await listAgentRunFailures({
|
|
213
|
+
sourceId: "all",
|
|
214
|
+
limit: 2,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(result.failures.map((failure) => failure.id)).toEqual([
|
|
218
|
+
"run-a-new",
|
|
219
|
+
"run-a-old",
|
|
220
|
+
]);
|
|
221
|
+
expect(result.failures[0]?.source.id).toBe("remote-a");
|
|
222
|
+
expect(result.partial).toBe(true);
|
|
223
|
+
expect(result.sources).toEqual(
|
|
224
|
+
expect.arrayContaining([
|
|
225
|
+
expect.objectContaining({
|
|
226
|
+
source: expect.objectContaining({ id: "current" }),
|
|
227
|
+
status: "ok",
|
|
228
|
+
}),
|
|
229
|
+
expect.objectContaining({
|
|
230
|
+
source: expect.objectContaining({ id: "remote-a" }),
|
|
231
|
+
status: "ok",
|
|
232
|
+
}),
|
|
233
|
+
expect.objectContaining({
|
|
234
|
+
source: expect.objectContaining({ id: "remote-b" }),
|
|
235
|
+
status: "unsupported",
|
|
236
|
+
errorCode: "thread_debug_schema_unsupported",
|
|
237
|
+
}),
|
|
238
|
+
expect.objectContaining({
|
|
239
|
+
source: expect.objectContaining({ id: "remote-c" }),
|
|
240
|
+
status: "unavailable",
|
|
241
|
+
errorCode: "thread_debug_source_unavailable",
|
|
242
|
+
}),
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
expect(
|
|
246
|
+
mocks.createDbExec.mock.calls.every(
|
|
247
|
+
([config]) => !JSON.stringify(config).includes("owner@example.com"),
|
|
248
|
+
),
|
|
249
|
+
).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("retains disconnected configured sources without attempting a connection", async () => {
|
|
253
|
+
vi.stubEnv("DISPATCH_ADMIN_EMAILS", "owner@example.com");
|
|
254
|
+
vi.stubEnv(
|
|
255
|
+
"AGENT_NATIVE_THREAD_DEBUG_DATABASES",
|
|
256
|
+
JSON.stringify([
|
|
257
|
+
{
|
|
258
|
+
id: "missing-prod",
|
|
259
|
+
label: "Missing Prod",
|
|
260
|
+
databaseUrlEnv: "MISSING_PROD_DATABASE_URL",
|
|
261
|
+
},
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
264
|
+
mocks.currentExecute.mockResolvedValue({ rows: [] });
|
|
265
|
+
|
|
266
|
+
const listed = await listThreadDebugSources();
|
|
267
|
+
expect(listed.sources).toEqual(
|
|
268
|
+
expect.arrayContaining([
|
|
269
|
+
expect.objectContaining({
|
|
270
|
+
id: "missing-prod",
|
|
271
|
+
connected: false,
|
|
272
|
+
databaseUrlEnv: "MISSING_PROD_DATABASE_URL",
|
|
273
|
+
}),
|
|
274
|
+
]),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const result = await listAgentRunFailures({ sourceId: "all" });
|
|
278
|
+
expect(result.sources).toEqual(
|
|
279
|
+
expect.arrayContaining([
|
|
280
|
+
expect.objectContaining({
|
|
281
|
+
source: expect.objectContaining({ id: "missing-prod" }),
|
|
282
|
+
status: "disconnected",
|
|
283
|
+
errorCode: "thread_debug_source_disconnected",
|
|
284
|
+
}),
|
|
285
|
+
]),
|
|
286
|
+
);
|
|
287
|
+
expect(mocks.createDbExec).not.toHaveBeenCalled();
|
|
288
|
+
await expect(
|
|
289
|
+
listAgentRunFailures({ sourceId: "missing-prod" }),
|
|
290
|
+
).rejects.toThrow("configured but disconnected");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("does not misclassify a missing additive column as a missing table", async () => {
|
|
294
|
+
mocks.currentExecute.mockRejectedValue(
|
|
295
|
+
new Error("SQLITE_ERROR: no such column: r.worker_stage"),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const result = await listAgentRunFailures({ sourceId: "current" });
|
|
299
|
+
|
|
300
|
+
expect(result.sources).toEqual([
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
status: "unavailable",
|
|
303
|
+
errorCode: "thread_debug_source_unavailable",
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("limits all-source requests to the current database for non-admins", async () => {
|
|
309
|
+
vi.stubEnv("REMOTE_DATABASE_URL", "libsql://remote");
|
|
310
|
+
mocks.currentExecute.mockImplementation(async ({ sql }) => ({
|
|
311
|
+
rows: sql.includes("JOIN chat_threads")
|
|
312
|
+
? [failureRow("run-current", Date.now())]
|
|
313
|
+
: [],
|
|
314
|
+
}));
|
|
315
|
+
|
|
316
|
+
const result = await listAgentRunFailures({ sourceId: "all" });
|
|
317
|
+
|
|
318
|
+
expect(result.sources).toEqual([
|
|
319
|
+
expect.objectContaining({
|
|
320
|
+
source: expect.objectContaining({ id: "current" }),
|
|
321
|
+
status: "ok",
|
|
322
|
+
}),
|
|
323
|
+
]);
|
|
324
|
+
expect(result.failures.map((failure) => failure.id)).toEqual([
|
|
325
|
+
"run-current",
|
|
326
|
+
]);
|
|
327
|
+
expect(mocks.createDbExec).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("keeps explicit remote sources admin-only", async () => {
|
|
331
|
+
vi.stubEnv("REMOTE_DATABASE_URL", "libsql://remote");
|
|
332
|
+
|
|
333
|
+
await expect(listAgentRunFailures({ sourceId: "remote" })).rejects.toThrow(
|
|
334
|
+
"Only Dispatch admins",
|
|
335
|
+
);
|
|
336
|
+
expect(mocks.createDbExec).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("prevents organization admins from requesting an owner outside their organization", async () => {
|
|
340
|
+
mocks.orgId = "org-1";
|
|
341
|
+
mocks.currentExecute.mockImplementation(async ({ sql }) => {
|
|
342
|
+
if (sql.includes("SELECT role FROM org_members")) {
|
|
343
|
+
return { rows: [{ role: "admin" }] };
|
|
344
|
+
}
|
|
345
|
+
if (sql.includes("SELECT email FROM org_members")) {
|
|
346
|
+
return { rows: [{ email: "owner@example.com" }] };
|
|
347
|
+
}
|
|
348
|
+
return { rows: [] };
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await expect(
|
|
352
|
+
listAgentRunFailures({
|
|
353
|
+
sourceId: "current",
|
|
354
|
+
ownerEmail: "outsider@example.com",
|
|
355
|
+
}),
|
|
356
|
+
).rejects.toThrow("not a member of the current organization");
|
|
81
357
|
});
|
|
82
358
|
});
|