@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,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
- execute: vi.fn(),
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: vi.fn(),
9
- getDbExec: () => ({ execute: mocks.execute }),
11
+ createDbExec: mocks.createDbExec,
12
+ getDbExec: () => ({ execute: mocks.currentExecute }),
10
13
  }));
11
14
 
12
15
  vi.mock("./dispatch-store.js", () => ({
13
- currentOrgId: () => null,
14
- currentOwnerEmail: () => "owner@example.com",
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
- status: "completed",
41
+ turn_id: "turn-1",
42
+ status: "errored",
43
+ abort_reason: null,
37
44
  started_at: 1,
38
- completed_at: 2,
39
- heartbeat_at: 2,
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 rowsForQuery(sql: string, args: unknown[]) {
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 (sql.includes("FROM agent_runs") && sql.includes("WHERE thread_id = ?")) {
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
- describe("thread-debug-store request/run lookup", () => {
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.execute.mockImplementation(async ({ sql, args }) => ({
61
- rows: rowsForQuery(sql, args),
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 before loading its debug snapshot", async () => {
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.thread.id).toBe(thread.id);
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
  });