@desplega.ai/agent-swarm 1.86.0 → 1.87.0

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 (47) hide show
  1. package/openapi.json +72 -1
  2. package/package.json +3 -1
  3. package/src/be/db-queries/tracker.ts +21 -0
  4. package/src/be/db.ts +235 -14
  5. package/src/be/migrations/079_task_followup_config.sql +1 -0
  6. package/src/be/modelsdev-cache.json +77663 -74073
  7. package/src/cli.tsx +26 -0
  8. package/src/commands/context-preamble.ts +272 -0
  9. package/src/commands/e2b.ts +728 -0
  10. package/src/commands/resume-session.ts +35 -78
  11. package/src/commands/runner.ts +125 -13
  12. package/src/e2b/dispatch.ts +429 -0
  13. package/src/e2b/env.ts +206 -0
  14. package/src/heartbeat/heartbeat.ts +145 -30
  15. package/src/heartbeat/templates.ts +11 -7
  16. package/src/http/session-data.ts +8 -1
  17. package/src/http/tasks.ts +152 -3
  18. package/src/jira/sync.ts +4 -4
  19. package/src/linear/sync.ts +6 -5
  20. package/src/providers/claude-adapter.ts +10 -76
  21. package/src/providers/claude-managed-adapter.ts +61 -75
  22. package/src/providers/codex-adapter.ts +15 -18
  23. package/src/providers/codex-oauth/auth-json.ts +18 -1
  24. package/src/providers/codex-oauth/flow.ts +24 -1
  25. package/src/providers/types.ts +6 -0
  26. package/src/tasks/worker-follow-up.ts +162 -2
  27. package/src/telemetry.ts +11 -1
  28. package/src/tests/claude-adapter.test.ts +5 -27
  29. package/src/tests/claude-managed-adapter.test.ts +38 -52
  30. package/src/tests/codex-adapter.test.ts +6 -31
  31. package/src/tests/codex-oauth.test.ts +149 -3
  32. package/src/tests/codex-pool.test.ts +14 -3
  33. package/src/tests/e2b-dispatch.test.ts +330 -0
  34. package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
  35. package/src/tests/heartbeat.test.ts +26 -16
  36. package/src/tests/prompt-template-remaining.test.ts +4 -0
  37. package/src/tests/resume-session.test.ts +42 -50
  38. package/src/tests/structured-output.test.ts +69 -0
  39. package/src/tests/task-completion-idempotency.test.ts +185 -2
  40. package/src/tests/task-supersede-resume.test.ts +722 -0
  41. package/src/tests/telemetry-init.test.ts +69 -0
  42. package/src/tests/vcs-tracking.test.ts +39 -0
  43. package/src/tools/send-task.ts +12 -1
  44. package/src/tools/store-progress.ts +2 -2
  45. package/src/tools/templates.ts +14 -2
  46. package/src/types.ts +46 -1
  47. package/src/workflows/executors/agent-task.ts +3 -0
@@ -0,0 +1,722 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ cancelTask,
5
+ closeDb,
6
+ completeTask,
7
+ createAgent,
8
+ createTaskExtended,
9
+ failTask,
10
+ getDb,
11
+ getLogsByTaskId,
12
+ getTaskById,
13
+ initDb,
14
+ startTask,
15
+ supersedeTask,
16
+ updateAgentStatus,
17
+ } from "../be/db";
18
+ import {
19
+ createTrackerSync,
20
+ getTrackerSync,
21
+ getTrackerSyncByExternalId,
22
+ } from "../be/db-queries/tracker";
23
+ import { buildResumeContextPreamble } from "../commands/context-preamble";
24
+ import { createResumeFollowUp } from "../tasks/worker-follow-up";
25
+
26
+ const TEST_DB_PATH = "./test-task-supersede-resume.sqlite";
27
+
28
+ async function cleanup() {
29
+ try {
30
+ await unlink(TEST_DB_PATH);
31
+ await unlink(`${TEST_DB_PATH}-wal`);
32
+ await unlink(`${TEST_DB_PATH}-shm`);
33
+ } catch {
34
+ // ignore
35
+ }
36
+ }
37
+
38
+ function freshAgent(prefix: string, opts?: { maxTasks?: number; lastActivityAt?: string }) {
39
+ const id = `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
40
+ const agent = createAgent({
41
+ id,
42
+ name: prefix,
43
+ isLead: false,
44
+ status: "idle",
45
+ });
46
+ if (opts?.maxTasks !== undefined || opts?.lastActivityAt) {
47
+ getDb().run(
48
+ "UPDATE agents SET maxTasks = COALESCE(?, maxTasks), lastActivityAt = COALESCE(?, lastActivityAt) WHERE id = ?",
49
+ [opts.maxTasks ?? null, opts.lastActivityAt ?? null, id],
50
+ );
51
+ }
52
+ return agent;
53
+ }
54
+
55
+ describe("Task Supersede + Resume", () => {
56
+ beforeAll(async () => {
57
+ await cleanup();
58
+ initDb(TEST_DB_PATH);
59
+ });
60
+
61
+ afterAll(async () => {
62
+ closeDb();
63
+ await cleanup();
64
+ });
65
+
66
+ // ──────────────────────────────────────────────────────────────────────────
67
+ // 1. supersedeTask() status transition + terminal guards
68
+ // ──────────────────────────────────────────────────────────────────────────
69
+
70
+ describe("supersedeTask()", () => {
71
+ test("transitions in_progress → superseded and sets finishedAt", () => {
72
+ const worker = freshAgent("worker-1");
73
+ const task = createTaskExtended("Test supersede transition", {
74
+ agentId: worker.id,
75
+ });
76
+ startTask(task.id);
77
+ const inProgress = getTaskById(task.id);
78
+ expect(inProgress?.status).toBe("in_progress");
79
+
80
+ const result = supersedeTask(task.id, {
81
+ reason: "graceful_shutdown",
82
+ resumeTaskId: null,
83
+ });
84
+ expect(result?.status).toBe("superseded");
85
+ expect(result?.finishedAt).toBeTruthy();
86
+
87
+ const log = getLogsByTaskId(task.id).find((l) => l.eventType === "task_superseded");
88
+ expect(log).toBeTruthy();
89
+ });
90
+
91
+ test("idempotent — second supersede returns null (alreadyFinished shape)", () => {
92
+ const worker = freshAgent("worker-1b");
93
+ const task = createTaskExtended("Idempotent supersede", { agentId: worker.id });
94
+ startTask(task.id);
95
+ const first = supersedeTask(task.id, { reason: "graceful_shutdown", resumeTaskId: null });
96
+ expect(first?.status).toBe("superseded");
97
+
98
+ const second = supersedeTask(task.id, {
99
+ reason: "graceful_shutdown",
100
+ resumeTaskId: null,
101
+ });
102
+ expect(second).toBeNull();
103
+ });
104
+
105
+ test("completeTask on a superseded task short-circuits", () => {
106
+ const worker = freshAgent("worker-2");
107
+ const task = createTaskExtended("Complete after supersede", { agentId: worker.id });
108
+ startTask(task.id);
109
+ supersedeTask(task.id, { reason: "graceful_shutdown", resumeTaskId: null });
110
+ const result = completeTask(task.id, "should not happen");
111
+ expect(result).toBeNull();
112
+ expect(getTaskById(task.id)?.status).toBe("superseded");
113
+ });
114
+
115
+ test("failTask on a superseded task short-circuits", () => {
116
+ const worker = freshAgent("worker-3");
117
+ const task = createTaskExtended("Fail after supersede", { agentId: worker.id });
118
+ startTask(task.id);
119
+ supersedeTask(task.id, { reason: "graceful_shutdown", resumeTaskId: null });
120
+ const result = failTask(task.id, "should not happen");
121
+ expect(result).toBeNull();
122
+ expect(getTaskById(task.id)?.status).toBe("superseded");
123
+ });
124
+
125
+ test("cancelTask on a superseded task short-circuits", () => {
126
+ const worker = freshAgent("worker-4");
127
+ const task = createTaskExtended("Cancel after supersede", { agentId: worker.id });
128
+ startTask(task.id);
129
+ supersedeTask(task.id, { reason: "graceful_shutdown", resumeTaskId: null });
130
+ const result = cancelTask(task.id, "should not happen");
131
+ expect(result).toBeNull();
132
+ expect(getTaskById(task.id)?.status).toBe("superseded");
133
+ });
134
+
135
+ test("supersede on already completed task returns null", () => {
136
+ const worker = freshAgent("worker-5");
137
+ const task = createTaskExtended("Complete then supersede", { agentId: worker.id });
138
+ startTask(task.id);
139
+ completeTask(task.id, "done");
140
+ const result = supersedeTask(task.id, {
141
+ reason: "graceful_shutdown",
142
+ resumeTaskId: null,
143
+ });
144
+ expect(result).toBeNull();
145
+ });
146
+ });
147
+
148
+ // ──────────────────────────────────────────────────────────────────────────
149
+ // 2. createResumeFollowUp()
150
+ // ──────────────────────────────────────────────────────────────────────────
151
+
152
+ describe("createResumeFollowUp()", () => {
153
+ test("non-workflow parent → creates resume task with inherited fields", () => {
154
+ const worker = freshAgent("worker-6", { lastActivityAt: new Date().toISOString() });
155
+ const parent = createTaskExtended("Parent with model+dir+vcs", {
156
+ agentId: worker.id,
157
+ model: "openrouter/openai/gpt-5-nano",
158
+ dir: "/workspace/project-x",
159
+ vcsRepo: "owner/repo",
160
+ vcsProvider: "github",
161
+ });
162
+ startTask(parent.id);
163
+
164
+ const result = createResumeFollowUp({
165
+ parentId: parent.id,
166
+ reason: "graceful_shutdown",
167
+ });
168
+ expect(result.kind).toBe("created");
169
+ if (result.kind !== "created") return;
170
+
171
+ const child = result.task;
172
+ expect(child.taskType).toBe("resume");
173
+ expect(child.parentTaskId).toBe(parent.id);
174
+ // `model` is DELIBERATELY NOT inherited: a resume task may be claimed by a
175
+ // different-provider worker, so it must resolve to the claiming agent's
176
+ // own model at session-init rather than the parent's concrete string.
177
+ expect(child.model).toBeUndefined();
178
+ expect(child.dir).toBe("/workspace/project-x");
179
+ expect(child.vcsRepo).toBe("owner/repo");
180
+ expect(child.vcsProvider).toBe("github");
181
+ expect(child.tags).toContain("auto-resume");
182
+ expect(child.tags).toContain("reason:graceful_shutdown");
183
+ expect(child.priority).toBeGreaterThanOrEqual(parent.priority);
184
+ });
185
+
186
+ // Guard at the single-source-of-truth level: any child created via
187
+ // `parentTaskId` must NOT inherit the parent's concrete `model`, but MUST
188
+ // still inherit other identity-shaped fields (dir, VCS). This is the
189
+ // consolidated fix covering resume tasks, completion/review follow-ups, and
190
+ // re-dispatches — a derived task on a different-provider agent would
191
+ // otherwise die at session-init with a model-incompatibility error.
192
+ test("createTaskExtended(parentTaskId) does NOT inherit model but DOES inherit dir/vcs", () => {
193
+ const parent = createTaskExtended("Parent pinned to a provider-specific model", {
194
+ agentId: freshAgent("worker-model-guard").id,
195
+ model: "claude-opus-4-8",
196
+ dir: "/workspace/project-y",
197
+ vcsRepo: "owner/repo2",
198
+ vcsProvider: "github",
199
+ });
200
+
201
+ const child = createTaskExtended("Derived task", {
202
+ source: "system",
203
+ taskType: "follow-up",
204
+ parentTaskId: parent.id,
205
+ });
206
+
207
+ // model NOT inherited → resolves to the assignee agent's own model
208
+ expect(child.model).toBeUndefined();
209
+ // other identity-shaped fields STILL inherit
210
+ expect(child.dir).toBe("/workspace/project-y");
211
+ expect(child.vcsRepo).toBe("owner/repo2");
212
+ expect(child.vcsProvider).toBe("github");
213
+
214
+ // An explicit model on the child is still honored (same-provider creator
215
+ // deliberately pinning a model is unaffected by the inheritance carve-out).
216
+ const explicitChild = createTaskExtended("Derived with explicit model", {
217
+ source: "system",
218
+ taskType: "follow-up",
219
+ parentTaskId: parent.id,
220
+ model: "sonnet",
221
+ });
222
+ expect(explicitChild.model).toBe("sonnet");
223
+ });
224
+
225
+ test("non-workflow parent with outputSchema → schema carries forward to resume child", () => {
226
+ const worker = freshAgent("worker-6-schema", {
227
+ lastActivityAt: new Date().toISOString(),
228
+ });
229
+ const schema = {
230
+ type: "object",
231
+ properties: {
232
+ status: { type: "string", enum: ["ok", "fail"] },
233
+ report: { type: "string" },
234
+ },
235
+ required: ["status"],
236
+ };
237
+ const parent = createTaskExtended("Parent with outputSchema", {
238
+ agentId: worker.id,
239
+ outputSchema: schema,
240
+ });
241
+ startTask(parent.id);
242
+
243
+ const result = createResumeFollowUp({
244
+ parentId: parent.id,
245
+ reason: "graceful_shutdown",
246
+ });
247
+ expect(result.kind).toBe("created");
248
+ if (result.kind !== "created") return;
249
+
250
+ // outputSchema must be preserved so `store-progress` still validates
251
+ // completion output and the runner still injects structured-output
252
+ // instructions (PR #594 review feedback).
253
+ expect(result.task.outputSchema).toEqual(schema);
254
+ });
255
+
256
+ test("non-workflow parent with full VCS identity → all VCS fields carry forward", () => {
257
+ // PR #594 review: codex flagged that `vcsNumber` (+ url/comment/installation/etc.)
258
+ // were dropped on resume, breaking webhook routing via findTaskByVcs.
259
+ // The fix lives in `createTaskExtended`'s parent-inheritance block —
260
+ // this test guards against regression for ALL VCS identity fields at once.
261
+ const worker = freshAgent("worker-vcs", {
262
+ lastActivityAt: new Date().toISOString(),
263
+ });
264
+ const parent = createTaskExtended("Parent with full VCS context", {
265
+ agentId: worker.id,
266
+ vcsProvider: "github",
267
+ vcsRepo: "desplega-ai/agent-swarm",
268
+ vcsNumber: 594,
269
+ vcsEventType: "pull_request.opened",
270
+ vcsCommentId: 12345,
271
+ vcsAuthor: "tarasyarema",
272
+ vcsUrl: "https://github.com/desplega-ai/agent-swarm/pull/594",
273
+ vcsInstallationId: 999,
274
+ vcsNodeId: "PR_kwDOQr3Tmc7abcdef",
275
+ });
276
+ startTask(parent.id);
277
+
278
+ const result = createResumeFollowUp({
279
+ parentId: parent.id,
280
+ reason: "context_limits",
281
+ });
282
+ if (result.kind !== "created") throw new Error("expected created");
283
+
284
+ expect(result.task.vcsProvider).toBe("github");
285
+ expect(result.task.vcsRepo).toBe("desplega-ai/agent-swarm");
286
+ expect(result.task.vcsNumber).toBe(594);
287
+ expect(result.task.vcsEventType).toBe("pull_request.opened");
288
+ expect(result.task.vcsCommentId).toBe(12345);
289
+ expect(result.task.vcsAuthor).toBe("tarasyarema");
290
+ expect(result.task.vcsUrl).toBe("https://github.com/desplega-ai/agent-swarm/pull/594");
291
+ expect(result.task.vcsInstallationId).toBe(999);
292
+ expect(result.task.vcsNodeId).toBe("PR_kwDOQr3Tmc7abcdef");
293
+ });
294
+
295
+ test("Linear-backed parent → tracker_sync row repoints to resume child", () => {
296
+ // PR #594 review: tracker_sync rows stayed keyed to the (now-terminal)
297
+ // parent after supersede. Linear outbound completion posts look up by
298
+ // swarmId, so the resume child's completion never made it back; and
299
+ // subsequent inbound events found the terminal parent in tracker_sync
300
+ // and created duplicate tasks.
301
+ const worker = freshAgent("worker-tracker", {
302
+ lastActivityAt: new Date().toISOString(),
303
+ });
304
+ const parent = createTaskExtended("Parent tracked in Linear", {
305
+ agentId: worker.id,
306
+ });
307
+ startTask(parent.id);
308
+
309
+ // Simulate the Linear sync row created when the issue was inbound-claimed.
310
+ createTrackerSync({
311
+ provider: "linear",
312
+ entityType: "task",
313
+ swarmId: parent.id,
314
+ externalId: "linear-issue-uuid-12345",
315
+ externalIdentifier: "ENG-42",
316
+ externalUrl: "https://linear.app/test/issue/ENG-42",
317
+ });
318
+
319
+ // Sanity: tracker_sync starts pointed at the parent.
320
+ const before = getTrackerSync("linear", "task", parent.id);
321
+ expect(before).not.toBeNull();
322
+
323
+ const result = createResumeFollowUp({
324
+ parentId: parent.id,
325
+ reason: "graceful_shutdown",
326
+ });
327
+ if (result.kind !== "created") throw new Error("expected created");
328
+
329
+ // After resume creation, tracker_sync should now key on the resume child.
330
+ const parentLookup = getTrackerSync("linear", "task", parent.id);
331
+ expect(parentLookup).toBeNull();
332
+ const childLookup = getTrackerSync("linear", "task", result.task.id);
333
+ expect(childLookup).not.toBeNull();
334
+ // External identity stays — only swarmId moved.
335
+ const byExternal = getTrackerSyncByExternalId("linear", "task", "linear-issue-uuid-12345");
336
+ expect(byExternal?.swarmId).toBe(result.task.id);
337
+ expect(byExternal?.externalIdentifier).toBe("ENG-42");
338
+ });
339
+
340
+ test("Parent with no tracker_sync → resume creation is a no-op on tracker_sync", () => {
341
+ const worker = freshAgent("worker-no-tracker", {
342
+ lastActivityAt: new Date().toISOString(),
343
+ });
344
+ const parent = createTaskExtended("Parent without tracker", { agentId: worker.id });
345
+ startTask(parent.id);
346
+
347
+ const result = createResumeFollowUp({
348
+ parentId: parent.id,
349
+ reason: "graceful_shutdown",
350
+ });
351
+ // Just assert it doesn't blow up — repoint returns 0 rows and the
352
+ // resume task still gets created cleanly.
353
+ expect(result.kind).toBe("created");
354
+ });
355
+
356
+ test("workflow-step parent → returns workflow-skip (no task created)", () => {
357
+ const worker = freshAgent("worker-7");
358
+ const parent = createTaskExtended("Workflow-step parent", {
359
+ agentId: worker.id,
360
+ });
361
+ // Backfill workflowRunStepId directly (createTaskExtended doesn't take
362
+ // it). Temporarily disable FKs since this test exercises only the
363
+ // supersede carve-out, not the workflow engine itself.
364
+ const stepId = crypto.randomUUID();
365
+ getDb().exec("PRAGMA foreign_keys = OFF");
366
+ try {
367
+ getDb().run("UPDATE agent_tasks SET workflowRunStepId = ? WHERE id = ?", [
368
+ stepId,
369
+ parent.id,
370
+ ]);
371
+ } finally {
372
+ getDb().exec("PRAGMA foreign_keys = ON");
373
+ }
374
+ startTask(parent.id);
375
+
376
+ const before = getDb()
377
+ .prepare<{ count: number }, []>(
378
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE taskType = 'resume'",
379
+ )
380
+ .get();
381
+ const beforeCount = before?.count ?? 0;
382
+
383
+ const result = createResumeFollowUp({
384
+ parentId: parent.id,
385
+ reason: "graceful_shutdown",
386
+ });
387
+ expect(result.kind).toBe("workflow-skip");
388
+ if (result.kind === "workflow-skip") {
389
+ expect(result.stepId).toBe(stepId);
390
+ }
391
+
392
+ const after = getDb()
393
+ .prepare<{ count: number }, []>(
394
+ "SELECT COUNT(*) as count FROM agent_tasks WHERE taskType = 'resume'",
395
+ )
396
+ .get();
397
+ const afterCount = after?.count ?? 0;
398
+ expect(afterCount).toBe(beforeCount);
399
+ });
400
+
401
+ test("routing: graceful_shutdown ALWAYS goes to pool, even on fresh+capable worker (PR #594 review)", () => {
402
+ // The worker is exiting moments after this check — keeping the resume on
403
+ // the same agent would orphan it once `closeAgent` runs. graceful_shutdown
404
+ // must force the unassigned-pool path regardless of liveness.
405
+ const worker = freshAgent("worker-fresh-shutdown", {
406
+ maxTasks: 5,
407
+ lastActivityAt: new Date().toISOString(),
408
+ });
409
+ const parent = createTaskExtended("Routing graceful_shutdown", { agentId: worker.id });
410
+ startTask(parent.id);
411
+
412
+ const result = createResumeFollowUp({
413
+ parentId: parent.id,
414
+ reason: "graceful_shutdown",
415
+ });
416
+ if (result.kind !== "created") throw new Error("expected created");
417
+ expect(result.task.agentId).toBeNull();
418
+ expect(result.task.status).toBe("unassigned");
419
+ });
420
+
421
+ test("routing: fresh worker + capacity (non-shutdown) → resume pre-assigned to same worker", () => {
422
+ // For context_limits / manual_supersede the worker is alive and can
423
+ // continue handling the resume on a fresh session.
424
+ const worker = freshAgent("worker-fresh", {
425
+ maxTasks: 5,
426
+ lastActivityAt: new Date().toISOString(),
427
+ });
428
+ const parent = createTaskExtended("Routing fresh", { agentId: worker.id });
429
+ startTask(parent.id);
430
+
431
+ const result = createResumeFollowUp({
432
+ parentId: parent.id,
433
+ reason: "context_limits",
434
+ });
435
+ if (result.kind !== "created") throw new Error("expected created");
436
+ expect(result.task.agentId).toBe(worker.id);
437
+ expect(result.task.status).toBe("pending");
438
+ });
439
+
440
+ test("routing: stale heartbeat → unassigned", () => {
441
+ const worker = freshAgent("worker-stale", {
442
+ maxTasks: 5,
443
+ lastActivityAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
444
+ });
445
+ const parent = createTaskExtended("Routing stale", { agentId: worker.id });
446
+ startTask(parent.id);
447
+
448
+ const result = createResumeFollowUp({
449
+ parentId: parent.id,
450
+ reason: "context_limits",
451
+ });
452
+ if (result.kind !== "created") throw new Error("expected created");
453
+ expect(result.task.agentId).toBeNull();
454
+ expect(result.task.status).toBe("unassigned");
455
+ });
456
+
457
+ test("routing: worker at capacity → unassigned", () => {
458
+ const worker = freshAgent("worker-full", {
459
+ maxTasks: 1,
460
+ lastActivityAt: new Date().toISOString(),
461
+ });
462
+ // Parent is already in_progress, which counts as 1 in_progress task →
463
+ // worker has zero remaining capacity (maxTasks=1).
464
+ const parent = createTaskExtended("Routing capped", { agentId: worker.id });
465
+ startTask(parent.id);
466
+
467
+ const result = createResumeFollowUp({
468
+ parentId: parent.id,
469
+ reason: "context_limits",
470
+ });
471
+ if (result.kind !== "created") throw new Error("expected created");
472
+ expect(result.task.agentId).toBeNull();
473
+ });
474
+
475
+ test("routing: offline worker → unassigned", () => {
476
+ const worker = freshAgent("worker-offline", {
477
+ maxTasks: 5,
478
+ lastActivityAt: new Date().toISOString(),
479
+ });
480
+ updateAgentStatus(worker.id, "offline");
481
+ const parent = createTaskExtended("Routing offline", { agentId: worker.id });
482
+ startTask(parent.id);
483
+
484
+ const result = createResumeFollowUp({
485
+ parentId: parent.id,
486
+ reason: "context_limits",
487
+ });
488
+ if (result.kind !== "created") throw new Error("expected created");
489
+ expect(result.task.agentId).toBeNull();
490
+ });
491
+
492
+ test("missing parent → skipped(parent_not_found)", () => {
493
+ const result = createResumeFollowUp({
494
+ parentId: "00000000-0000-0000-0000-000000000000",
495
+ reason: "graceful_shutdown",
496
+ });
497
+ expect(result.kind).toBe("skipped");
498
+ });
499
+ });
500
+
501
+ // ──────────────────────────────────────────────────────────────────────────
502
+ // 3. buildResumeContextPreamble()
503
+ // ──────────────────────────────────────────────────────────────────────────
504
+
505
+ describe("buildResumeContextPreamble()", () => {
506
+ // Spin a tiny HTTP server emulating the two endpoints the preamble fetches.
507
+ let server: import("node:http").Server | undefined;
508
+ let baseUrl = "";
509
+ let testTaskId = "";
510
+ let testTaskDescription = "";
511
+ let mockSessionLogs: Array<{ createdAt: string; content: string }> = [];
512
+
513
+ beforeAll(async () => {
514
+ const { createServer } = await import("node:http");
515
+ testTaskId = crypto.randomUUID();
516
+ testTaskDescription =
517
+ "Build a feature that processes user uploads end-to-end. " +
518
+ "Include validation, virus scan, S3 upload, and a notification.";
519
+ mockSessionLogs = [];
520
+
521
+ server = createServer((req, res) => {
522
+ res.setHeader("Content-Type", "application/json");
523
+ const url = req.url ?? "";
524
+ if (url === `/api/tasks/${testTaskId}`) {
525
+ res.writeHead(200);
526
+ res.end(
527
+ JSON.stringify({
528
+ id: testTaskId,
529
+ task: testTaskDescription,
530
+ attachments: [],
531
+ }),
532
+ );
533
+ return;
534
+ }
535
+ if (url === `/api/tasks/${testTaskId}/session-logs`) {
536
+ res.writeHead(200);
537
+ res.end(JSON.stringify({ logs: mockSessionLogs }));
538
+ return;
539
+ }
540
+ res.writeHead(404);
541
+ res.end(JSON.stringify({ error: "not found" }));
542
+ });
543
+ const port = 13099;
544
+ await new Promise<void>((r) => server?.listen(port, () => r()));
545
+ baseUrl = `http://localhost:${port}`;
546
+ });
547
+
548
+ afterAll(async () => {
549
+ if (server) {
550
+ await new Promise<void>((r) => server?.close(() => r()));
551
+ }
552
+ });
553
+
554
+ test("preserves the full parent task description", async () => {
555
+ mockSessionLogs = [];
556
+ const preamble = await buildResumeContextPreamble(baseUrl, "", testTaskId);
557
+ expect(preamble).toBeTruthy();
558
+ expect(preamble).toContain(testTaskDescription);
559
+ expect(preamble).toContain("Resuming Interrupted Task");
560
+ });
561
+
562
+ test("scrubs secret-shaped values from session-log summaries", async () => {
563
+ // GitHub PAT-shaped token — matches a structural pattern in
564
+ // scrubSecrets (regardless of env state).
565
+ const fakeToken = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
566
+ mockSessionLogs = [
567
+ {
568
+ createdAt: new Date().toISOString(),
569
+ content: JSON.stringify({
570
+ type: "tool_use",
571
+ name: "Bash",
572
+ input: { command: `curl -H 'Authorization: ${fakeToken}' https://api` },
573
+ }),
574
+ },
575
+ ];
576
+
577
+ const preamble = await buildResumeContextPreamble(baseUrl, "", testTaskId);
578
+ expect(preamble).toBeTruthy();
579
+ expect(preamble).not.toContain(fakeToken);
580
+ });
581
+
582
+ test("respects the 4000-token (16000-char) cap when over budget", async () => {
583
+ // Generate a lot of session logs to push past the cap.
584
+ mockSessionLogs = Array.from({ length: 200 }, (_, i) => ({
585
+ createdAt: new Date().toISOString(),
586
+ content: JSON.stringify({
587
+ type: "tool_use",
588
+ name: "Read",
589
+ input: { file_path: `/workspace/src/long-path-${i}/file-${i}.ts` },
590
+ }),
591
+ }));
592
+
593
+ const preamble = await buildResumeContextPreamble(baseUrl, "", testTaskId);
594
+ expect(preamble).toBeTruthy();
595
+ const text = preamble ?? "";
596
+ // 4000 tokens * 4 chars/token = 16_000 chars hard cap; allow small
597
+ // trailing truncation marker.
598
+ expect(text.length).toBeLessThanOrEqual(16_500);
599
+ // Description must remain intact.
600
+ expect(text).toContain(testTaskDescription);
601
+ });
602
+
603
+ test("returns null when parent task is not found", async () => {
604
+ const preamble = await buildResumeContextPreamble(
605
+ baseUrl,
606
+ "",
607
+ "00000000-0000-0000-0000-000000000999",
608
+ );
609
+ expect(preamble).toBeNull();
610
+ });
611
+
612
+ test("cascading resume: walks chain to ORIGINAL task and merges logs across attempts", async () => {
613
+ // PR #594 review: a resume task being superseded again would have
614
+ // `buildResumeContextPreamble` reading the immediate parent's synthetic
615
+ // "Resume interrupted task..." prompt instead of the real description,
616
+ // and session logs scoped only to that one resume attempt. The fix
617
+ // walks the parentTaskId chain through taskType="resume" ancestors.
618
+ const originalId = crypto.randomUUID();
619
+ const resume1Id = crypto.randomUUID();
620
+ const originalDescription =
621
+ "ORIGINAL: implement /api/widgets endpoint with full pagination + validation.";
622
+ const resume1SyntheticPrompt =
623
+ "Resume interrupted task.\n\nParent task: ORIGINAL: implement /api/widgets...\n\nReason: graceful_shutdown\n\n[synthetic]";
624
+
625
+ // resume2 is what the runner is about to launch. Its `parentTaskId`
626
+ // is resume1, which is `taskType="resume"`, whose `parentTaskId` is
627
+ // the original (non-resume).
628
+ const chainServer = (await import("node:http")).createServer((req, res) => {
629
+ res.setHeader("Content-Type", "application/json");
630
+ const url = req.url ?? "";
631
+ if (url === `/api/tasks/${resume1Id}`) {
632
+ res.writeHead(200).end(
633
+ JSON.stringify({
634
+ id: resume1Id,
635
+ task: resume1SyntheticPrompt,
636
+ taskType: "resume",
637
+ parentTaskId: originalId,
638
+ attachments: [],
639
+ }),
640
+ );
641
+ return;
642
+ }
643
+ if (url === `/api/tasks/${originalId}`) {
644
+ res.writeHead(200).end(
645
+ JSON.stringify({
646
+ id: originalId,
647
+ task: originalDescription,
648
+ taskType: undefined,
649
+ parentTaskId: undefined,
650
+ attachments: [],
651
+ }),
652
+ );
653
+ return;
654
+ }
655
+ if (url?.startsWith(`/api/tasks/${resume1Id}/session-logs`)) {
656
+ res.writeHead(200).end(
657
+ JSON.stringify({
658
+ logs: [
659
+ {
660
+ createdAt: "2026-05-29T12:00:00.000Z",
661
+ content: JSON.stringify({
662
+ type: "tool_use",
663
+ name: "RecentResumeAttempt",
664
+ input: { file_path: "/from/resume1" },
665
+ }),
666
+ },
667
+ ],
668
+ }),
669
+ );
670
+ return;
671
+ }
672
+ if (url?.startsWith(`/api/tasks/${originalId}/session-logs`)) {
673
+ res.writeHead(200).end(
674
+ JSON.stringify({
675
+ logs: [
676
+ {
677
+ createdAt: "2026-05-29T10:00:00.000Z",
678
+ content: JSON.stringify({
679
+ type: "tool_use",
680
+ name: "OriginalTaskWork",
681
+ input: { file_path: "/from/original" },
682
+ }),
683
+ },
684
+ ],
685
+ }),
686
+ );
687
+ return;
688
+ }
689
+ res.writeHead(404).end(JSON.stringify({ error: "not found" }));
690
+ });
691
+ const port = 13100;
692
+ await new Promise<void>((r) => chainServer.listen(port, () => r()));
693
+
694
+ try {
695
+ // resume2's parentTaskId = resume1.id → walk should reach original.
696
+ const preamble = await buildResumeContextPreamble(
697
+ `http://localhost:${port}`,
698
+ "",
699
+ resume1Id,
700
+ );
701
+ expect(preamble).toBeTruthy();
702
+ const text = preamble ?? "";
703
+
704
+ // The ORIGINAL task description must be in the preamble.
705
+ expect(text).toContain(originalDescription);
706
+ // The synthetic "Resume interrupted task" body of the immediate
707
+ // parent must NOT be the surfaced description.
708
+ expect(text).not.toContain(resume1SyntheticPrompt);
709
+ // The original task ID must be the one referenced (not the resume).
710
+ expect(text).toContain(originalId);
711
+ // Tool-call summaries from BOTH chain members merged (verify by
712
+ // presence of both unique names).
713
+ expect(text).toContain("OriginalTaskWork");
714
+ expect(text).toContain("RecentResumeAttempt");
715
+ // Chain-depth notice present (>1 chain length).
716
+ expect(text).toContain("Resume chain depth: 2");
717
+ } finally {
718
+ await new Promise<void>((r) => chainServer.close(() => r()));
719
+ }
720
+ });
721
+ });
722
+ });