@herdctl/core 1.3.1 → 2.0.1

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 (187) hide show
  1. package/dist/config/__tests__/agent.test.js +12 -12
  2. package/dist/config/__tests__/agent.test.js.map +1 -1
  3. package/dist/config/__tests__/loader.test.js +201 -4
  4. package/dist/config/__tests__/loader.test.js.map +1 -1
  5. package/dist/config/__tests__/merge.test.js +29 -4
  6. package/dist/config/__tests__/merge.test.js.map +1 -1
  7. package/dist/config/__tests__/parser.test.js +13 -13
  8. package/dist/config/__tests__/parser.test.js.map +1 -1
  9. package/dist/config/__tests__/schema.test.js +10 -10
  10. package/dist/config/__tests__/schema.test.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/index.js +2 -2
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/loader.d.ts.map +1 -1
  16. package/dist/config/loader.js +71 -0
  17. package/dist/config/loader.js.map +1 -1
  18. package/dist/config/merge.d.ts +4 -1
  19. package/dist/config/merge.d.ts.map +1 -1
  20. package/dist/config/merge.js +16 -0
  21. package/dist/config/merge.js.map +1 -1
  22. package/dist/config/schema.d.ts +906 -89
  23. package/dist/config/schema.d.ts.map +1 -1
  24. package/dist/config/schema.js +109 -7
  25. package/dist/config/schema.js.map +1 -1
  26. package/dist/fleet-manager/__tests__/coverage.test.js +25 -24
  27. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
  28. package/dist/fleet-manager/__tests__/discord-manager.test.js +9 -2
  29. package/dist/fleet-manager/__tests__/discord-manager.test.js.map +1 -1
  30. package/dist/fleet-manager/__tests__/integration.test.js +27 -0
  31. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  32. package/dist/fleet-manager/__tests__/job-control.test.js +66 -0
  33. package/dist/fleet-manager/__tests__/job-control.test.js.map +1 -1
  34. package/dist/fleet-manager/__tests__/status-queries.test.js +12 -11
  35. package/dist/fleet-manager/__tests__/status-queries.test.js.map +1 -1
  36. package/dist/fleet-manager/config-reload.js +9 -9
  37. package/dist/fleet-manager/config-reload.js.map +1 -1
  38. package/dist/fleet-manager/discord-manager.d.ts.map +1 -1
  39. package/dist/fleet-manager/discord-manager.js +27 -4
  40. package/dist/fleet-manager/discord-manager.js.map +1 -1
  41. package/dist/fleet-manager/fleet-manager.d.ts +11 -0
  42. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  43. package/dist/fleet-manager/fleet-manager.js +27 -0
  44. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  45. package/dist/fleet-manager/job-control.d.ts +1 -1
  46. package/dist/fleet-manager/job-control.d.ts.map +1 -1
  47. package/dist/fleet-manager/job-control.js +36 -14
  48. package/dist/fleet-manager/job-control.js.map +1 -1
  49. package/dist/fleet-manager/schedule-executor.d.ts +1 -1
  50. package/dist/fleet-manager/schedule-executor.d.ts.map +1 -1
  51. package/dist/fleet-manager/schedule-executor.js +17 -17
  52. package/dist/fleet-manager/schedule-executor.js.map +1 -1
  53. package/dist/fleet-manager/status-queries.js +7 -7
  54. package/dist/fleet-manager/status-queries.js.map +1 -1
  55. package/dist/fleet-manager/types.d.ts +10 -2
  56. package/dist/fleet-manager/types.d.ts.map +1 -1
  57. package/dist/fleet-manager/working-directory-helper.d.ts +29 -0
  58. package/dist/fleet-manager/working-directory-helper.d.ts.map +1 -0
  59. package/dist/fleet-manager/working-directory-helper.js +36 -0
  60. package/dist/fleet-manager/working-directory-helper.js.map +1 -0
  61. package/dist/hooks/__tests__/discord-runner.test.js +16 -16
  62. package/dist/hooks/__tests__/discord-runner.test.js.map +1 -1
  63. package/dist/hooks/runners/discord.d.ts.map +1 -1
  64. package/dist/hooks/runners/discord.js +15 -12
  65. package/dist/hooks/runners/discord.js.map +1 -1
  66. package/dist/runner/__tests__/job-executor.test.js +461 -126
  67. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  68. package/dist/runner/__tests__/message-processor.test.js +12 -35
  69. package/dist/runner/__tests__/message-processor.test.js.map +1 -1
  70. package/dist/runner/__tests__/sdk-adapter.test.js +137 -2
  71. package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -1
  72. package/dist/runner/index.d.ts +2 -0
  73. package/dist/runner/index.d.ts.map +1 -1
  74. package/dist/runner/index.js +1 -0
  75. package/dist/runner/index.js.map +1 -1
  76. package/dist/runner/job-executor.d.ts +12 -8
  77. package/dist/runner/job-executor.d.ts.map +1 -1
  78. package/dist/runner/job-executor.js +280 -133
  79. package/dist/runner/job-executor.js.map +1 -1
  80. package/dist/runner/message-processor.d.ts +5 -2
  81. package/dist/runner/message-processor.d.ts.map +1 -1
  82. package/dist/runner/message-processor.js +9 -18
  83. package/dist/runner/message-processor.js.map +1 -1
  84. package/dist/runner/runtime/__tests__/cli-session-path.test.d.ts +2 -0
  85. package/dist/runner/runtime/__tests__/cli-session-path.test.d.ts.map +1 -0
  86. package/dist/runner/runtime/__tests__/cli-session-path.test.js +150 -0
  87. package/dist/runner/runtime/__tests__/cli-session-path.test.js.map +1 -0
  88. package/dist/runner/runtime/__tests__/docker-config.test.d.ts +2 -0
  89. package/dist/runner/runtime/__tests__/docker-config.test.d.ts.map +1 -0
  90. package/dist/runner/runtime/__tests__/docker-config.test.js +352 -0
  91. package/dist/runner/runtime/__tests__/docker-config.test.js.map +1 -0
  92. package/dist/runner/runtime/__tests__/docker-security.test.d.ts +2 -0
  93. package/dist/runner/runtime/__tests__/docker-security.test.d.ts.map +1 -0
  94. package/dist/runner/runtime/__tests__/docker-security.test.js +384 -0
  95. package/dist/runner/runtime/__tests__/docker-security.test.js.map +1 -0
  96. package/dist/runner/runtime/__tests__/factory.test.d.ts +2 -0
  97. package/dist/runner/runtime/__tests__/factory.test.d.ts.map +1 -0
  98. package/dist/runner/runtime/__tests__/factory.test.js +149 -0
  99. package/dist/runner/runtime/__tests__/factory.test.js.map +1 -0
  100. package/dist/runner/runtime/__tests__/integration.test.d.ts +2 -0
  101. package/dist/runner/runtime/__tests__/integration.test.d.ts.map +1 -0
  102. package/dist/runner/runtime/__tests__/integration.test.js +274 -0
  103. package/dist/runner/runtime/__tests__/integration.test.js.map +1 -0
  104. package/dist/runner/runtime/cli-runtime.d.ts +107 -0
  105. package/dist/runner/runtime/cli-runtime.d.ts.map +1 -0
  106. package/dist/runner/runtime/cli-runtime.js +341 -0
  107. package/dist/runner/runtime/cli-runtime.js.map +1 -0
  108. package/dist/runner/runtime/cli-session-path.d.ts +108 -0
  109. package/dist/runner/runtime/cli-session-path.d.ts.map +1 -0
  110. package/dist/runner/runtime/cli-session-path.js +173 -0
  111. package/dist/runner/runtime/cli-session-path.js.map +1 -0
  112. package/dist/runner/runtime/cli-session-watcher.d.ts +55 -0
  113. package/dist/runner/runtime/cli-session-watcher.d.ts.map +1 -0
  114. package/dist/runner/runtime/cli-session-watcher.js +187 -0
  115. package/dist/runner/runtime/cli-session-watcher.js.map +1 -0
  116. package/dist/runner/runtime/container-manager.d.ts +76 -0
  117. package/dist/runner/runtime/container-manager.d.ts.map +1 -0
  118. package/dist/runner/runtime/container-manager.js +229 -0
  119. package/dist/runner/runtime/container-manager.js.map +1 -0
  120. package/dist/runner/runtime/container-runner.d.ts +62 -0
  121. package/dist/runner/runtime/container-runner.d.ts.map +1 -0
  122. package/dist/runner/runtime/container-runner.js +235 -0
  123. package/dist/runner/runtime/container-runner.js.map +1 -0
  124. package/dist/runner/runtime/docker-config.d.ts +100 -0
  125. package/dist/runner/runtime/docker-config.d.ts.map +1 -0
  126. package/dist/runner/runtime/docker-config.js +98 -0
  127. package/dist/runner/runtime/docker-config.js.map +1 -0
  128. package/dist/runner/runtime/factory.d.ts +63 -0
  129. package/dist/runner/runtime/factory.d.ts.map +1 -0
  130. package/dist/runner/runtime/factory.js +68 -0
  131. package/dist/runner/runtime/factory.js.map +1 -0
  132. package/dist/runner/runtime/index.d.ts +20 -0
  133. package/dist/runner/runtime/index.d.ts.map +1 -0
  134. package/dist/runner/runtime/index.js +21 -0
  135. package/dist/runner/runtime/index.js.map +1 -0
  136. package/dist/runner/runtime/interface.d.ts +59 -0
  137. package/dist/runner/runtime/interface.d.ts.map +1 -0
  138. package/dist/runner/runtime/interface.js +12 -0
  139. package/dist/runner/runtime/interface.js.map +1 -0
  140. package/dist/runner/runtime/sdk-runtime.d.ts +46 -0
  141. package/dist/runner/runtime/sdk-runtime.d.ts.map +1 -0
  142. package/dist/runner/runtime/sdk-runtime.js +63 -0
  143. package/dist/runner/runtime/sdk-runtime.js.map +1 -0
  144. package/dist/runner/sdk-adapter.d.ts.map +1 -1
  145. package/dist/runner/sdk-adapter.js +28 -10
  146. package/dist/runner/sdk-adapter.js.map +1 -1
  147. package/dist/runner/types.d.ts +11 -1
  148. package/dist/runner/types.d.ts.map +1 -1
  149. package/dist/scheduler/__tests__/schedule-runner.test.js +61 -50
  150. package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -1
  151. package/dist/scheduler/schedule-runner.d.ts +1 -4
  152. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  153. package/dist/scheduler/schedule-runner.js +40 -8
  154. package/dist/scheduler/schedule-runner.js.map +1 -1
  155. package/dist/state/__tests__/session-schema.test.js +4 -0
  156. package/dist/state/__tests__/session-schema.test.js.map +1 -1
  157. package/dist/state/__tests__/session-validation.test.d.ts +2 -0
  158. package/dist/state/__tests__/session-validation.test.d.ts.map +1 -0
  159. package/dist/state/__tests__/session-validation.test.js +446 -0
  160. package/dist/state/__tests__/session-validation.test.js.map +1 -0
  161. package/dist/state/__tests__/session.test.js +68 -0
  162. package/dist/state/__tests__/session.test.js.map +1 -1
  163. package/dist/state/__tests__/working-directory-validation.test.d.ts +5 -0
  164. package/dist/state/__tests__/working-directory-validation.test.d.ts.map +1 -0
  165. package/dist/state/__tests__/working-directory-validation.test.js +101 -0
  166. package/dist/state/__tests__/working-directory-validation.test.js.map +1 -0
  167. package/dist/state/index.d.ts +2 -0
  168. package/dist/state/index.d.ts.map +1 -1
  169. package/dist/state/index.js +4 -0
  170. package/dist/state/index.js.map +1 -1
  171. package/dist/state/schemas/session-info.d.ts +32 -0
  172. package/dist/state/schemas/session-info.d.ts.map +1 -1
  173. package/dist/state/schemas/session-info.js +22 -0
  174. package/dist/state/schemas/session-info.js.map +1 -1
  175. package/dist/state/session-validation.d.ts +227 -0
  176. package/dist/state/session-validation.d.ts.map +1 -0
  177. package/dist/state/session-validation.js +448 -0
  178. package/dist/state/session-validation.js.map +1 -0
  179. package/dist/state/session.d.ts +23 -3
  180. package/dist/state/session.d.ts.map +1 -1
  181. package/dist/state/session.js +41 -6
  182. package/dist/state/session.js.map +1 -1
  183. package/dist/state/working-directory-validation.d.ts +52 -0
  184. package/dist/state/working-directory-validation.d.ts.map +1 -0
  185. package/dist/state/working-directory-validation.js +81 -0
  186. package/dist/state/working-directory-validation.js.map +1 -0
  187. package/package.json +7 -2
@@ -3,7 +3,7 @@ import { mkdir, rm, realpath, readFile, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { JobExecutor, executeJob } from "../job-executor.js";
6
- import { getJob, readJobOutputAll, initStateDirectory, getSessionInfo, } from "../../state/index.js";
6
+ import { getJob, readJobOutputAll, initStateDirectory, getSessionInfo, updateSessionInfo, } from "../../state/index.js";
7
7
  // =============================================================================
8
8
  // Test Helpers
9
9
  // =============================================================================
@@ -35,28 +35,34 @@ function createMockLogger() {
35
35
  },
36
36
  };
37
37
  }
38
- // Helper to create a mock SDK query function
39
- function createMockSDKQuery(messages) {
40
- return async function* mockQuery() {
38
+ // Helper to create mock RuntimeInterface
39
+ function createMockRuntime(handler) {
40
+ return {
41
+ execute: handler,
42
+ };
43
+ }
44
+ // Helper to create a mock runtime with predefined messages
45
+ function createMockRuntimeWithMessages(messages) {
46
+ return createMockRuntime(async function* () {
41
47
  for (const message of messages) {
42
48
  yield message;
43
49
  }
44
- };
50
+ });
45
51
  }
46
- // Helper to create a mock SDK query that yields messages with delays
47
- function createDelayedSDKQuery(messages, delayMs = 10) {
48
- return async function* mockQuery() {
52
+ // Helper to create a mock runtime that yields messages with delays
53
+ function createDelayedMockRuntime(messages, delayMs = 10) {
54
+ return createMockRuntime(async function* () {
49
55
  for (const message of messages) {
50
56
  await new Promise((resolve) => setTimeout(resolve, delayMs));
51
57
  yield message;
52
58
  }
53
- };
59
+ });
54
60
  }
55
- // Helper to create a mock SDK query that throws an error
56
- function createErrorSDKQuery(error) {
57
- return async function* mockQuery() {
61
+ // Helper to create a mock runtime that throws an error
62
+ function createErrorMockRuntime(error) {
63
+ return createMockRuntime(async function* () {
58
64
  throw error;
59
- };
65
+ });
60
66
  }
61
67
  // =============================================================================
62
68
  // JobExecutor tests
@@ -78,7 +84,7 @@ describe("JobExecutor", () => {
78
84
  { type: "system", content: "Initialized" },
79
85
  { type: "assistant", content: "Done" },
80
86
  ];
81
- const executor = new JobExecutor(createMockSDKQuery(messages), {
87
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
82
88
  logger: createMockLogger(),
83
89
  });
84
90
  const result = await executor.execute({
@@ -93,11 +99,11 @@ describe("JobExecutor", () => {
93
99
  });
94
100
  it("updates job status to running", async () => {
95
101
  let jobIdDuringExecution;
96
- const sdkQuery = async function* () {
102
+ const runtime = createMockRuntime(async function* (options) {
97
103
  // During execution, we can check the job status
98
104
  yield { type: "system", content: "Running" };
99
- };
100
- const executor = new JobExecutor(sdkQuery, {
105
+ });
106
+ const executor = new JobExecutor(runtime, {
101
107
  logger: createMockLogger(),
102
108
  });
103
109
  const result = await executor.execute({
@@ -114,7 +120,7 @@ describe("JobExecutor", () => {
114
120
  { type: "system", content: "Start" },
115
121
  { type: "assistant", content: "Task completed!" },
116
122
  ];
117
- const executor = new JobExecutor(createMockSDKQuery(messages), {
123
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
118
124
  logger: createMockLogger(),
119
125
  });
120
126
  const result = await executor.execute({
@@ -134,7 +140,7 @@ describe("JobExecutor", () => {
134
140
  { type: "system", content: "Start" },
135
141
  { type: "error", message: "Something went wrong" },
136
142
  ];
137
- const executor = new JobExecutor(createMockSDKQuery(messages), {
143
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
138
144
  logger: createMockLogger(),
139
145
  });
140
146
  const result = await executor.execute({
@@ -149,7 +155,7 @@ describe("JobExecutor", () => {
149
155
  expect(job?.exit_reason).toBe("error");
150
156
  });
151
157
  it("handles SDK query throwing an error", async () => {
152
- const executor = new JobExecutor(createErrorSDKQuery(new Error("SDK error")), { logger: createMockLogger() });
158
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("SDK error")), { logger: createMockLogger() });
153
159
  const result = await executor.execute({
154
160
  agent: createTestAgent(),
155
161
  prompt: "Test prompt",
@@ -170,7 +176,7 @@ describe("JobExecutor", () => {
170
176
  { type: "tool_result", result: "file1\nfile2", success: true },
171
177
  { type: "assistant", content: "Done" },
172
178
  ];
173
- const executor = new JobExecutor(createMockSDKQuery(messages), {
179
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
174
180
  logger: createMockLogger(),
175
181
  });
176
182
  const result = await executor.execute({
@@ -189,14 +195,14 @@ describe("JobExecutor", () => {
189
195
  it("writes output immediately without buffering", async () => {
190
196
  let outputCountDuringExecution = 0;
191
197
  const jobsDir = join(stateDir, "jobs");
192
- const sdkQuery = async function* () {
198
+ const runtime = createMockRuntime(async function* (options) {
193
199
  yield { type: "system", content: "First" };
194
200
  yield { type: "assistant", content: "Second" };
195
201
  yield { type: "assistant", content: "Third" };
196
- };
202
+ });
197
203
  // We can't easily verify real-time writing in a unit test,
198
204
  // but we can verify all messages are written
199
- const executor = new JobExecutor(sdkQuery, {
205
+ const executor = new JobExecutor(runtime, {
200
206
  logger: createMockLogger(),
201
207
  });
202
208
  const result = await executor.execute({
@@ -228,7 +234,7 @@ describe("JobExecutor", () => {
228
234
  success: true,
229
235
  },
230
236
  ];
231
- const executor = new JobExecutor(createMockSDKQuery(messages), {
237
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
232
238
  logger: createMockLogger(),
233
239
  });
234
240
  const result = await executor.execute({
@@ -266,7 +272,7 @@ describe("JobExecutor", () => {
266
272
  }
267
273
  });
268
274
  it("writes error message to output when SDK throws", async () => {
269
- const executor = new JobExecutor(createErrorSDKQuery(new Error("Connection failed")), { logger: createMockLogger() });
275
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("Connection failed")), { logger: createMockLogger() });
270
276
  const result = await executor.execute({
271
277
  agent: createTestAgent(),
272
278
  prompt: "Test prompt",
@@ -289,7 +295,7 @@ describe("JobExecutor", () => {
289
295
  { type: "tool_result", result: "output", success: true },
290
296
  { type: "error", message: "Error message", code: "ERR_TEST" },
291
297
  ];
292
- const executor = new JobExecutor(createMockSDKQuery(messages), {
298
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
293
299
  logger: createMockLogger(),
294
300
  });
295
301
  const result = await executor.execute({
@@ -310,7 +316,7 @@ describe("JobExecutor", () => {
310
316
  { type: "assistant", content: "Part 1... Part 2...", partial: true },
311
317
  { type: "assistant", content: "Part 1... Part 2... Done!", partial: false },
312
318
  ];
313
- const executor = new JobExecutor(createMockSDKQuery(messages), {
319
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
314
320
  logger: createMockLogger(),
315
321
  });
316
322
  const result = await executor.execute({
@@ -332,7 +338,7 @@ describe("JobExecutor", () => {
332
338
  { type: "tool_use", tool_name: "read_file", input: { path: "/nope" } },
333
339
  { type: "tool_result", success: false, error: "File not found" },
334
340
  ];
335
- const executor = new JobExecutor(createMockSDKQuery(messages), {
341
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
336
342
  logger: createMockLogger(),
337
343
  });
338
344
  const result = await executor.execute({
@@ -353,7 +359,7 @@ describe("JobExecutor", () => {
353
359
  { type: "system", content: "Init", subtype: "init", session_id: "session-abc123" },
354
360
  { type: "assistant", content: "Done" },
355
361
  ];
356
- const executor = new JobExecutor(createMockSDKQuery(messages), {
362
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
357
363
  logger: createMockLogger(),
358
364
  });
359
365
  const result = await executor.execute({
@@ -370,7 +376,7 @@ describe("JobExecutor", () => {
370
376
  { type: "system", content: "Progress", subtype: "progress", session_id: "should-ignore" },
371
377
  { type: "assistant", content: "Done" },
372
378
  ];
373
- const executor = new JobExecutor(createMockSDKQuery(messages), {
379
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
374
380
  logger: createMockLogger(),
375
381
  });
376
382
  const result = await executor.execute({
@@ -387,7 +393,7 @@ describe("JobExecutor", () => {
387
393
  { type: "system", content: "Init", subtype: "init", session_id: "session-persist-123" },
388
394
  { type: "assistant", content: "Done" },
389
395
  ];
390
- const executor = new JobExecutor(createMockSDKQuery(messages), {
396
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
391
397
  logger: createMockLogger(),
392
398
  });
393
399
  await executor.execute({
@@ -408,7 +414,7 @@ describe("JobExecutor", () => {
408
414
  { type: "system", content: "Init", subtype: "init", session_id: "session-multi-123" },
409
415
  { type: "assistant", content: "Done" },
410
416
  ];
411
- const executor = new JobExecutor(createMockSDKQuery(messages), {
417
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
412
418
  logger: createMockLogger(),
413
419
  });
414
420
  // Run twice
@@ -431,7 +437,7 @@ describe("JobExecutor", () => {
431
437
  { type: "system", content: "Init", subtype: "init", session_id: "session-ts-123" },
432
438
  { type: "assistant", content: "Done" },
433
439
  ];
434
- const executor = new JobExecutor(createMockSDKQuery(messages), {
440
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
435
441
  logger: createMockLogger(),
436
442
  });
437
443
  await executor.execute({
@@ -451,7 +457,7 @@ describe("JobExecutor", () => {
451
457
  { type: "system", content: "Init", subtype: "init", session_id: "session-result-123" },
452
458
  { type: "assistant", content: "Done" },
453
459
  ];
454
- const executor = new JobExecutor(createMockSDKQuery(messages), {
460
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
455
461
  logger: createMockLogger(),
456
462
  });
457
463
  const result = await executor.execute({
@@ -463,12 +469,18 @@ describe("JobExecutor", () => {
463
469
  expect(result.sessionId).toBe("session-result-123");
464
470
  });
465
471
  it("passes resume option to SDK", async () => {
472
+ // Create a valid session so resume isn't cleared
473
+ const sessionsDir = join(stateDir, "sessions");
474
+ await updateSessionInfo(sessionsDir, "test-agent", {
475
+ session_id: "session-to-resume",
476
+ mode: "autonomous",
477
+ });
466
478
  let receivedOptions;
467
- const sdkQuery = async function* (params) {
468
- receivedOptions = params.options;
479
+ const runtime = createMockRuntime(async function* (options) {
480
+ receivedOptions = options;
469
481
  yield { type: "assistant", content: "Resumed" };
470
- };
471
- const executor = new JobExecutor(sdkQuery, {
482
+ });
483
+ const executor = new JobExecutor(runtime, {
472
484
  logger: createMockLogger(),
473
485
  });
474
486
  await executor.execute({
@@ -481,11 +493,11 @@ describe("JobExecutor", () => {
481
493
  });
482
494
  it("passes fork option to SDK", async () => {
483
495
  let receivedOptions;
484
- const sdkQuery = async function* (params) {
485
- receivedOptions = params.options;
496
+ const runtime = createMockRuntime(async function* (options) {
497
+ receivedOptions = options;
486
498
  yield { type: "assistant", content: "Forked" };
487
- };
488
- const executor = new JobExecutor(sdkQuery, {
499
+ });
500
+ const executor = new JobExecutor(runtime, {
489
501
  logger: createMockLogger(),
490
502
  });
491
503
  await executor.execute({
@@ -494,14 +506,14 @@ describe("JobExecutor", () => {
494
506
  stateDir,
495
507
  fork: "session-to-fork",
496
508
  });
497
- expect(receivedOptions?.forkSession).toBe(true);
509
+ expect(receivedOptions?.fork).toBe(true);
498
510
  });
499
511
  it("creates job with trigger_type 'fork' and forked_from when forking", async () => {
500
512
  const messages = [
501
513
  { type: "system", content: "Init", subtype: "init", session_id: "forked-session-123" },
502
514
  { type: "assistant", content: "Forked session started" },
503
515
  ];
504
- const executor = new JobExecutor(createMockSDKQuery(messages), {
516
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
505
517
  logger: createMockLogger(),
506
518
  });
507
519
  const result = await executor.execute({
@@ -520,7 +532,7 @@ describe("JobExecutor", () => {
520
532
  const messages = [
521
533
  { type: "assistant", content: "Forked" },
522
534
  ];
523
- const executor = new JobExecutor(createMockSDKQuery(messages), {
535
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
524
536
  logger: createMockLogger(),
525
537
  });
526
538
  const result = await executor.execute({
@@ -537,7 +549,7 @@ describe("JobExecutor", () => {
537
549
  const messages = [
538
550
  { type: "assistant", content: "Normal run" },
539
551
  ];
540
- const executor = new JobExecutor(createMockSDKQuery(messages), {
552
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
541
553
  logger: createMockLogger(),
542
554
  });
543
555
  const result = await executor.execute({
@@ -554,7 +566,7 @@ describe("JobExecutor", () => {
554
566
  { type: "system", content: "Init", subtype: "init", session_id: "resume-session-123" },
555
567
  { type: "assistant", content: "Resumed" },
556
568
  ];
557
- const executor = new JobExecutor(createMockSDKQuery(messages), {
569
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
558
570
  logger: createMockLogger(),
559
571
  });
560
572
  // First run to establish the session
@@ -582,7 +594,7 @@ describe("JobExecutor", () => {
582
594
  { type: "system", content: "Init", subtype: "init", session_id: "fork-session-123" },
583
595
  { type: "assistant", content: "Forked" },
584
596
  ];
585
- const executor = new JobExecutor(createMockSDKQuery(messages), {
597
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
586
598
  logger: createMockLogger(),
587
599
  });
588
600
  // First run to establish a session
@@ -611,7 +623,7 @@ describe("JobExecutor", () => {
611
623
  { type: "system", content: "No session" },
612
624
  { type: "assistant", content: "Done" },
613
625
  ];
614
- const executor = new JobExecutor(createMockSDKQuery(messages), {
626
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
615
627
  logger: createMockLogger(),
616
628
  });
617
629
  await executor.execute({
@@ -630,7 +642,7 @@ describe("JobExecutor", () => {
630
642
  { type: "assistant", content: "Working..." },
631
643
  { type: "assistant", content: "Done!", summary: "Task completed" },
632
644
  ];
633
- const executor = new JobExecutor(createMockSDKQuery(messages), {
645
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
634
646
  logger: createMockLogger(),
635
647
  });
636
648
  const result = await executor.execute({
@@ -647,7 +659,7 @@ describe("JobExecutor", () => {
647
659
  { type: "assistant", content: "Working..." },
648
660
  { type: "assistant", content: "All tasks finished successfully." },
649
661
  ];
650
- const executor = new JobExecutor(createMockSDKQuery(messages), {
662
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
651
663
  logger: createMockLogger(),
652
664
  });
653
665
  const result = await executor.execute({
@@ -663,7 +675,7 @@ describe("JobExecutor", () => {
663
675
  { type: "tool_use", tool_name: "bash", input: { command: "ls" } },
664
676
  { type: "tool_result", result: "file1\nfile2", success: true },
665
677
  ];
666
- const executor = new JobExecutor(createMockSDKQuery(messages), {
678
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
667
679
  logger: createMockLogger(),
668
680
  });
669
681
  const result = await executor.execute({
@@ -680,7 +692,7 @@ describe("JobExecutor", () => {
680
692
  { type: "assistant", content: "Partial 1...", partial: true },
681
693
  { type: "assistant", content: "Partial 2...", partial: true },
682
694
  ];
683
- const executor = new JobExecutor(createMockSDKQuery(messages), {
695
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
684
696
  logger: createMockLogger(),
685
697
  });
686
698
  const result = await executor.execute({
@@ -690,13 +702,13 @@ describe("JobExecutor", () => {
690
702
  });
691
703
  expect(result.summary).toBeUndefined();
692
704
  });
693
- it("returns undefined summary when all assistant messages are too long", async () => {
694
- const longContent = "x".repeat(501);
705
+ it("returns full summary for long assistant messages (no truncation)", async () => {
706
+ const longContent = "x".repeat(5000);
695
707
  const messages = [
696
708
  { type: "assistant", content: longContent },
697
709
  { type: "assistant", content: longContent },
698
710
  ];
699
- const executor = new JobExecutor(createMockSDKQuery(messages), {
711
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
700
712
  logger: createMockLogger(),
701
713
  });
702
714
  const result = await executor.execute({
@@ -704,14 +716,17 @@ describe("JobExecutor", () => {
704
716
  prompt: "Test prompt",
705
717
  stateDir,
706
718
  });
707
- expect(result.summary).toBeUndefined();
719
+ // No truncation at this layer - downstream consumers (Discord) handle their own limits
720
+ expect(result.summary).toBeDefined();
721
+ expect(result.summary).toBe(longContent);
722
+ expect(result.summary?.length).toBe(5000);
708
723
  });
709
- it("truncates long explicit summary to 500 chars", async () => {
710
- const longSummary = "x".repeat(600);
724
+ it("returns full explicit summary (no truncation)", async () => {
725
+ const longSummary = "x".repeat(5000);
711
726
  const messages = [
712
727
  { type: "assistant", content: "Done!", summary: longSummary },
713
728
  ];
714
- const executor = new JobExecutor(createMockSDKQuery(messages), {
729
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
715
730
  logger: createMockLogger(),
716
731
  });
717
732
  const result = await executor.execute({
@@ -719,11 +734,12 @@ describe("JobExecutor", () => {
719
734
  prompt: "Test prompt",
720
735
  stateDir,
721
736
  });
737
+ // No truncation at this layer
722
738
  expect(result.summary).toBeDefined();
723
- expect(result.summary.length).toBe(500);
724
- expect(result.summary.endsWith("...")).toBe(true);
739
+ expect(result.summary).toBe(longSummary);
740
+ expect(result.summary.length).toBe(5000);
725
741
  const job = await getJob(join(stateDir, "jobs"), result.jobId);
726
- expect(job?.summary?.length).toBe(500);
742
+ expect(job?.summary).toBe(longSummary);
727
743
  });
728
744
  it("uses latest summary when multiple messages have summaries", async () => {
729
745
  const messages = [
@@ -731,7 +747,7 @@ describe("JobExecutor", () => {
731
747
  { type: "assistant", content: "Second", summary: "Second summary" },
732
748
  { type: "assistant", content: "Third", summary: "Final summary" },
733
749
  ];
734
- const executor = new JobExecutor(createMockSDKQuery(messages), {
750
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
735
751
  logger: createMockLogger(),
736
752
  });
737
753
  const result = await executor.execute({
@@ -743,7 +759,7 @@ describe("JobExecutor", () => {
743
759
  });
744
760
  it("handles empty message stream with undefined summary", async () => {
745
761
  const messages = [];
746
- const executor = new JobExecutor(createMockSDKQuery(messages), {
762
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
747
763
  logger: createMockLogger(),
748
764
  });
749
765
  const result = await executor.execute({
@@ -765,7 +781,7 @@ describe("JobExecutor", () => {
765
781
  const onMessage = vi.fn((msg) => {
766
782
  receivedMessages.push(msg);
767
783
  });
768
- const executor = new JobExecutor(createMockSDKQuery(messages), {
784
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
769
785
  logger: createMockLogger(),
770
786
  });
771
787
  await executor.execute({
@@ -788,7 +804,7 @@ describe("JobExecutor", () => {
788
804
  const onMessage = vi.fn(() => {
789
805
  throw new Error("Callback error");
790
806
  });
791
- const executor = new JobExecutor(createMockSDKQuery(messages), {
807
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
792
808
  logger: createMockLogger(),
793
809
  });
794
810
  const result = await executor.execute({
@@ -808,7 +824,7 @@ describe("JobExecutor", () => {
808
824
  const onMessage = vi.fn(async () => {
809
825
  await new Promise((resolve) => setTimeout(resolve, 10));
810
826
  });
811
- const executor = new JobExecutor(createMockSDKQuery(messages), {
827
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
812
828
  logger: createMockLogger(),
813
829
  });
814
830
  await executor.execute({
@@ -823,7 +839,7 @@ describe("JobExecutor", () => {
823
839
  describe("trigger types", () => {
824
840
  it("sets trigger type to manual by default", async () => {
825
841
  const messages = [{ type: "assistant", content: "Done" }];
826
- const executor = new JobExecutor(createMockSDKQuery(messages), {
842
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
827
843
  logger: createMockLogger(),
828
844
  });
829
845
  const result = await executor.execute({
@@ -836,7 +852,7 @@ describe("JobExecutor", () => {
836
852
  });
837
853
  it("sets trigger type from options", async () => {
838
854
  const messages = [{ type: "assistant", content: "Done" }];
839
- const executor = new JobExecutor(createMockSDKQuery(messages), {
855
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
840
856
  logger: createMockLogger(),
841
857
  });
842
858
  const result = await executor.execute({
@@ -857,7 +873,7 @@ describe("JobExecutor", () => {
857
873
  { type: "system", content: "Start" },
858
874
  { type: "assistant", content: "End" },
859
875
  ];
860
- const executor = new JobExecutor(createDelayedSDKQuery(messages, 50), { logger: createMockLogger() });
876
+ const executor = new JobExecutor(createDelayedMockRuntime(messages, 50), { logger: createMockLogger() });
861
877
  const result = await executor.execute({
862
878
  agent: createTestAgent(),
863
879
  prompt: "Test prompt",
@@ -888,7 +904,7 @@ describe("executeJob", () => {
888
904
  { type: "system", content: "Init" },
889
905
  { type: "assistant", content: "Done" },
890
906
  ];
891
- const result = await executeJob(createMockSDKQuery(messages), {
907
+ const result = await executeJob(createMockRuntimeWithMessages(messages), {
892
908
  agent: createTestAgent({ name: "convenience-agent" }),
893
909
  prompt: "Test prompt",
894
910
  stateDir,
@@ -901,7 +917,7 @@ describe("executeJob", () => {
901
917
  it("passes executor options", async () => {
902
918
  const messages = [{ type: "assistant", content: "Done" }];
903
919
  const logger = createMockLogger();
904
- await executeJob(createMockSDKQuery(messages), {
920
+ await executeJob(createMockRuntimeWithMessages(messages), {
905
921
  agent: createTestAgent(),
906
922
  prompt: "Test prompt",
907
923
  stateDir,
@@ -924,7 +940,7 @@ describe("edge cases", () => {
924
940
  await rm(tempDir, { recursive: true, force: true });
925
941
  });
926
942
  it("handles empty message stream", async () => {
927
- const executor = new JobExecutor(createMockSDKQuery([]), {
943
+ const executor = new JobExecutor(createMockRuntimeWithMessages([]), {
928
944
  logger: createMockLogger(),
929
945
  });
930
946
  const result = await executor.execute({
@@ -941,7 +957,7 @@ describe("edge cases", () => {
941
957
  const messages = [
942
958
  { type: "assistant", content: longContent },
943
959
  ];
944
- const executor = new JobExecutor(createMockSDKQuery(messages), {
960
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
945
961
  logger: createMockLogger(),
946
962
  });
947
963
  const result = await executor.execute({
@@ -958,7 +974,7 @@ describe("edge cases", () => {
958
974
  const messages = [
959
975
  { type: "assistant", content: "Hello 世界! 🌍 Γεια σου κόσμε" },
960
976
  ];
961
- const executor = new JobExecutor(createMockSDKQuery(messages), {
977
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
962
978
  logger: createMockLogger(),
963
979
  });
964
980
  const result = await executor.execute({
@@ -978,7 +994,7 @@ describe("edge cases", () => {
978
994
  content: 'Content with "quotes", \\backslashes\\, and\nnewlines',
979
995
  },
980
996
  ];
981
- const executor = new JobExecutor(createMockSDKQuery(messages), {
997
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
982
998
  logger: createMockLogger(),
983
999
  });
984
1000
  const result = await executor.execute({
@@ -998,7 +1014,7 @@ describe("edge cases", () => {
998
1014
  type: "assistant",
999
1015
  content: `Message ${i}`,
1000
1016
  }));
1001
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1017
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1002
1018
  logger: createMockLogger(),
1003
1019
  });
1004
1020
  const result = await executor.execute({
@@ -1026,11 +1042,11 @@ describe("error handling (US-7)", () => {
1026
1042
  });
1027
1043
  describe("SDK initialization errors", () => {
1028
1044
  it("catches SDK initialization errors (e.g., missing API key)", async () => {
1029
- // Simulates SDK throwing immediately when query is created
1030
- const sdkQuery = () => {
1045
+ // Simulates SDK throwing immediately when execute is called
1046
+ const runtime = createMockRuntime(() => {
1031
1047
  throw new Error("ANTHROPIC_API_KEY environment variable is not set");
1032
- };
1033
- const executor = new JobExecutor(sdkQuery, {
1048
+ });
1049
+ const executor = new JobExecutor(runtime, {
1034
1050
  logger: createMockLogger(),
1035
1051
  });
1036
1052
  const result = await executor.execute({
@@ -1043,10 +1059,10 @@ describe("error handling (US-7)", () => {
1043
1059
  expect(result.errorDetails?.type).toBe("initialization");
1044
1060
  });
1045
1061
  it("provides context (job ID, agent name) in initialization error", async () => {
1046
- const sdkQuery = () => {
1062
+ const runtime = createMockRuntime(() => {
1047
1063
  throw new Error("SDK init failed");
1048
- };
1049
- const executor = new JobExecutor(sdkQuery, {
1064
+ });
1065
+ const executor = new JobExecutor(runtime, {
1050
1066
  logger: createMockLogger(),
1051
1067
  });
1052
1068
  const result = await executor.execute({
@@ -1060,12 +1076,12 @@ describe("error handling (US-7)", () => {
1060
1076
  });
1061
1077
  describe("SDK streaming errors", () => {
1062
1078
  it("catches SDK streaming errors during execution", async () => {
1063
- const sdkQuery = async function* () {
1079
+ const runtime = createMockRuntime(async function* (options) {
1064
1080
  yield { type: "system", content: "Init" };
1065
1081
  yield { type: "assistant", content: "Working..." };
1066
1082
  throw new Error("Connection reset by peer");
1067
- };
1068
- const executor = new JobExecutor(sdkQuery, {
1083
+ });
1084
+ const executor = new JobExecutor(runtime, {
1069
1085
  logger: createMockLogger(),
1070
1086
  });
1071
1087
  const result = await executor.execute({
@@ -1078,13 +1094,13 @@ describe("error handling (US-7)", () => {
1078
1094
  expect(result.errorDetails?.type).toBe("streaming");
1079
1095
  });
1080
1096
  it("tracks messages received before streaming error", async () => {
1081
- const sdkQuery = async function* () {
1097
+ const runtime = createMockRuntime(async function* (options) {
1082
1098
  yield { type: "system", content: "Init" };
1083
1099
  yield { type: "assistant", content: "Message 1" };
1084
1100
  yield { type: "assistant", content: "Message 2" };
1085
1101
  throw new Error("Stream interrupted");
1086
- };
1087
- const executor = new JobExecutor(sdkQuery, {
1102
+ });
1103
+ const executor = new JobExecutor(runtime, {
1088
1104
  logger: createMockLogger(),
1089
1105
  });
1090
1106
  const result = await executor.execute({
@@ -1096,11 +1112,11 @@ describe("error handling (US-7)", () => {
1096
1112
  expect(result.errorDetails?.messagesReceived).toBe(3);
1097
1113
  });
1098
1114
  it("identifies recoverable errors (rate limit)", async () => {
1099
- const sdkQuery = async function* () {
1115
+ const runtime = createMockRuntime(async function* (options) {
1100
1116
  yield { type: "system", content: "Init" };
1101
1117
  throw new Error("Rate limit exceeded, please retry");
1102
- };
1103
- const executor = new JobExecutor(sdkQuery, {
1118
+ });
1119
+ const executor = new JobExecutor(runtime, {
1104
1120
  logger: createMockLogger(),
1105
1121
  });
1106
1122
  const result = await executor.execute({
@@ -1111,11 +1127,11 @@ describe("error handling (US-7)", () => {
1111
1127
  expect(result.errorDetails?.recoverable).toBe(true);
1112
1128
  });
1113
1129
  it("identifies non-recoverable errors", async () => {
1114
- const sdkQuery = async function* () {
1130
+ const runtime = createMockRuntime(async function* (options) {
1115
1131
  yield { type: "system", content: "Init" };
1116
1132
  throw new Error("Invalid request format");
1117
- };
1118
- const executor = new JobExecutor(sdkQuery, {
1133
+ });
1134
+ const executor = new JobExecutor(runtime, {
1119
1135
  logger: createMockLogger(),
1120
1136
  });
1121
1137
  const result = await executor.execute({
@@ -1128,7 +1144,7 @@ describe("error handling (US-7)", () => {
1128
1144
  });
1129
1145
  describe("error logging to job output", () => {
1130
1146
  it("logs error messages to job output as error type messages", async () => {
1131
- const executor = new JobExecutor(createErrorSDKQuery(new Error("Test error for logging")), { logger: createMockLogger() });
1147
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("Test error for logging")), { logger: createMockLogger() });
1132
1148
  const result = await executor.execute({
1133
1149
  agent: createTestAgent(),
1134
1150
  prompt: "Test prompt",
@@ -1145,7 +1161,7 @@ describe("error handling (US-7)", () => {
1145
1161
  it("includes error code in job output when available", async () => {
1146
1162
  const errorWithCode = new Error("Network error");
1147
1163
  errorWithCode.code = "ECONNRESET";
1148
- const executor = new JobExecutor(createErrorSDKQuery(errorWithCode), {
1164
+ const executor = new JobExecutor(createErrorMockRuntime(errorWithCode), {
1149
1165
  logger: createMockLogger(),
1150
1166
  });
1151
1167
  const result = await executor.execute({
@@ -1161,7 +1177,7 @@ describe("error handling (US-7)", () => {
1161
1177
  });
1162
1178
  it("includes stack trace in job output", async () => {
1163
1179
  const error = new Error("Stack trace test");
1164
- const executor = new JobExecutor(createErrorSDKQuery(error), {
1180
+ const executor = new JobExecutor(createErrorMockRuntime(error), {
1165
1181
  logger: createMockLogger(),
1166
1182
  });
1167
1183
  const result = await executor.execute({
@@ -1179,7 +1195,7 @@ describe("error handling (US-7)", () => {
1179
1195
  });
1180
1196
  describe("job status updates", () => {
1181
1197
  it("updates job status to failed with error exit_reason", async () => {
1182
- const executor = new JobExecutor(createErrorSDKQuery(new Error("Failure")), { logger: createMockLogger() });
1198
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("Failure")), { logger: createMockLogger() });
1183
1199
  const result = await executor.execute({
1184
1200
  agent: createTestAgent(),
1185
1201
  prompt: "Test prompt",
@@ -1191,7 +1207,7 @@ describe("error handling (US-7)", () => {
1191
1207
  });
1192
1208
  it("sets exit_reason to timeout for timeout errors", async () => {
1193
1209
  const timeoutError = new Error("Request timed out");
1194
- const executor = new JobExecutor(createErrorSDKQuery(timeoutError), {
1210
+ const executor = new JobExecutor(createErrorMockRuntime(timeoutError), {
1195
1211
  logger: createMockLogger(),
1196
1212
  });
1197
1213
  const result = await executor.execute({
@@ -1204,7 +1220,7 @@ describe("error handling (US-7)", () => {
1204
1220
  });
1205
1221
  it("sets exit_reason to cancelled for abort errors", async () => {
1206
1222
  const abortError = new Error("Operation aborted by user");
1207
- const executor = new JobExecutor(createErrorSDKQuery(abortError), {
1223
+ const executor = new JobExecutor(createErrorMockRuntime(abortError), {
1208
1224
  logger: createMockLogger(),
1209
1225
  });
1210
1226
  const result = await executor.execute({
@@ -1217,7 +1233,7 @@ describe("error handling (US-7)", () => {
1217
1233
  });
1218
1234
  it("sets exit_reason to max_turns for turn limit errors", async () => {
1219
1235
  const maxTurnsError = new Error("Maximum turns exceeded");
1220
- const executor = new JobExecutor(createErrorSDKQuery(maxTurnsError), {
1236
+ const executor = new JobExecutor(createErrorMockRuntime(maxTurnsError), {
1221
1237
  logger: createMockLogger(),
1222
1238
  });
1223
1239
  const result = await executor.execute({
@@ -1231,7 +1247,7 @@ describe("error handling (US-7)", () => {
1231
1247
  });
1232
1248
  describe("error details in RunnerResult", () => {
1233
1249
  it("provides descriptive error message with context", async () => {
1234
- const executor = new JobExecutor(createErrorSDKQuery(new Error("API connection failed")), { logger: createMockLogger() });
1250
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("API connection failed")), { logger: createMockLogger() });
1235
1251
  const result = await executor.execute({
1236
1252
  agent: createTestAgent({ name: "descriptive-agent" }),
1237
1253
  prompt: "Test prompt",
@@ -1245,7 +1261,7 @@ describe("error handling (US-7)", () => {
1245
1261
  it("returns error details in RunnerResult", async () => {
1246
1262
  const errorWithCode = new Error("Network timeout");
1247
1263
  errorWithCode.code = "ETIMEDOUT";
1248
- const executor = new JobExecutor(createErrorSDKQuery(errorWithCode), {
1264
+ const executor = new JobExecutor(createErrorMockRuntime(errorWithCode), {
1249
1265
  logger: createMockLogger(),
1250
1266
  });
1251
1267
  const result = await executor.execute({
@@ -1261,13 +1277,13 @@ describe("error handling (US-7)", () => {
1261
1277
  });
1262
1278
  describe("malformed SDK responses", () => {
1263
1279
  it("does not crash on malformed SDK messages", async () => {
1264
- const sdkQuery = async function* () {
1280
+ const runtime = createMockRuntime(async function* (options) {
1265
1281
  yield { type: "system", content: "Init" };
1266
1282
  // Yield a malformed message (null)
1267
1283
  yield null;
1268
1284
  yield { type: "assistant", content: "Continuing after malformed" };
1269
- };
1270
- const executor = new JobExecutor(sdkQuery, {
1285
+ });
1286
+ const executor = new JobExecutor(runtime, {
1271
1287
  logger: createMockLogger(),
1272
1288
  });
1273
1289
  const result = await executor.execute({
@@ -1285,12 +1301,12 @@ describe("error handling (US-7)", () => {
1285
1301
  expect(malformedMsg).toBeDefined();
1286
1302
  });
1287
1303
  it("handles messages with missing type field", async () => {
1288
- const sdkQuery = async function* () {
1304
+ const runtime = createMockRuntime(async function* (options) {
1289
1305
  yield { type: "system", content: "Init" };
1290
1306
  yield { content: "Missing type" };
1291
1307
  yield { type: "assistant", content: "Done" };
1292
- };
1293
- const executor = new JobExecutor(sdkQuery, {
1308
+ });
1309
+ const executor = new JobExecutor(runtime, {
1294
1310
  logger: createMockLogger(),
1295
1311
  });
1296
1312
  const result = await executor.execute({
@@ -1305,12 +1321,12 @@ describe("error handling (US-7)", () => {
1305
1321
  expect(unknownTypeMsg).toBeDefined();
1306
1322
  });
1307
1323
  it("handles messages with unexpected type values", async () => {
1308
- const sdkQuery = async function* () {
1324
+ const runtime = createMockRuntime(async function* (options) {
1309
1325
  yield { type: "system", content: "Init" };
1310
1326
  yield { type: "unexpected_type", content: "Unknown" };
1311
1327
  yield { type: "assistant", content: "Done" };
1312
- };
1313
- const executor = new JobExecutor(sdkQuery, {
1328
+ });
1329
+ const executor = new JobExecutor(runtime, {
1314
1330
  logger: createMockLogger(),
1315
1331
  });
1316
1332
  const result = await executor.execute({
@@ -1328,7 +1344,7 @@ describe("error handling (US-7)", () => {
1328
1344
  { type: "system", content: "Start" },
1329
1345
  { type: "error", message: "SDK reported error", code: "SDK_ERR" },
1330
1346
  ];
1331
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1347
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1332
1348
  logger: createMockLogger(),
1333
1349
  });
1334
1350
  const result = await executor.execute({
@@ -1361,7 +1377,7 @@ describe("outputToFile (US-9)", () => {
1361
1377
  { type: "system", content: "Init" },
1362
1378
  { type: "assistant", content: "Hello" },
1363
1379
  ];
1364
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1380
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1365
1381
  logger: createMockLogger(),
1366
1382
  });
1367
1383
  const result = await executor.execute({
@@ -1380,7 +1396,7 @@ describe("outputToFile (US-9)", () => {
1380
1396
  { type: "system", content: "Init" },
1381
1397
  { type: "assistant", content: "Hello world" },
1382
1398
  ];
1383
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1399
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1384
1400
  logger: createMockLogger(),
1385
1401
  });
1386
1402
  const result = await executor.execute({
@@ -1400,7 +1416,7 @@ describe("outputToFile (US-9)", () => {
1400
1416
  { type: "system", content: "Init" },
1401
1417
  { type: "assistant", content: "Hello" },
1402
1418
  ];
1403
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1419
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1404
1420
  logger: createMockLogger(),
1405
1421
  });
1406
1422
  const result = await executor.execute({
@@ -1424,7 +1440,7 @@ describe("outputToFile (US-9)", () => {
1424
1440
  { type: "system", content: "Init" },
1425
1441
  { type: "assistant", content: "Hello" },
1426
1442
  ];
1427
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1443
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1428
1444
  logger: createMockLogger(),
1429
1445
  });
1430
1446
  const result = await executor.execute({
@@ -1451,7 +1467,7 @@ describe("outputToFile (US-9)", () => {
1451
1467
  { type: "tool_result", result: "localhost", success: true },
1452
1468
  { type: "error", message: "An error occurred" },
1453
1469
  ];
1454
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1470
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1455
1471
  logger: createMockLogger(),
1456
1472
  });
1457
1473
  const result = await executor.execute({
@@ -1472,7 +1488,7 @@ describe("outputToFile (US-9)", () => {
1472
1488
  const messages = [
1473
1489
  { type: "assistant", content: "Hello" },
1474
1490
  ];
1475
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1491
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1476
1492
  logger: createMockLogger(),
1477
1493
  });
1478
1494
  const result = await executor.execute({
@@ -1491,7 +1507,7 @@ describe("outputToFile (US-9)", () => {
1491
1507
  { type: "system", content: "Init" },
1492
1508
  { type: "assistant", content: "Response" },
1493
1509
  ];
1494
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1510
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1495
1511
  logger: createMockLogger(),
1496
1512
  });
1497
1513
  const result = await executor.execute({
@@ -1510,7 +1526,7 @@ describe("outputToFile (US-9)", () => {
1510
1526
  const messages = [
1511
1527
  { type: "tool_result", result: "File not found", success: false },
1512
1528
  ];
1513
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1529
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1514
1530
  logger: createMockLogger(),
1515
1531
  });
1516
1532
  const result = await executor.execute({
@@ -1532,7 +1548,7 @@ describe("outputToFile (US-9)", () => {
1532
1548
  { type: "system", content: "Init" },
1533
1549
  { type: "assistant", content: "Hello" },
1534
1550
  ];
1535
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1551
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1536
1552
  logger: createMockLogger(),
1537
1553
  });
1538
1554
  await executor.execute({
@@ -1547,4 +1563,323 @@ describe("outputToFile (US-9)", () => {
1547
1563
  expect(receivedMessages).toHaveLength(2);
1548
1564
  });
1549
1565
  });
1566
+ // =============================================================================
1567
+ // Session expiration handling tests (fixes unexpected logout bug)
1568
+ // =============================================================================
1569
+ describe("session expiration handling", () => {
1570
+ let tempDir;
1571
+ let stateDir;
1572
+ beforeEach(async () => {
1573
+ tempDir = await createTempDir();
1574
+ stateDir = join(tempDir, ".herdctl");
1575
+ await initStateDirectory({ path: stateDir });
1576
+ });
1577
+ afterEach(async () => {
1578
+ await rm(tempDir, { recursive: true, force: true });
1579
+ });
1580
+ it("starts fresh session when existing session is expired", async () => {
1581
+ const sessionsDir = join(stateDir, "sessions");
1582
+ // Create an expired session (last used 25 hours ago, default timeout is 24h)
1583
+ const expiredLastUsed = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
1584
+ await updateSessionInfo(sessionsDir, "expiry-test-agent", {
1585
+ session_id: "old-expired-session",
1586
+ mode: "autonomous",
1587
+ });
1588
+ // Manually update last_used_at to make it expired
1589
+ const { writeFile } = await import("node:fs/promises");
1590
+ const sessionPath = join(sessionsDir, "expiry-test-agent.json");
1591
+ const sessionData = {
1592
+ agent_name: "expiry-test-agent",
1593
+ session_id: "old-expired-session",
1594
+ created_at: expiredLastUsed,
1595
+ last_used_at: expiredLastUsed,
1596
+ job_count: 1,
1597
+ mode: "autonomous",
1598
+ };
1599
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1600
+ let receivedOptions;
1601
+ const runtime = createMockRuntime(async function* (options) {
1602
+ receivedOptions = options;
1603
+ yield { type: "system", content: "Init", subtype: "init", session_id: "new-fresh-session" };
1604
+ yield { type: "assistant", content: "Started fresh" };
1605
+ });
1606
+ const executor = new JobExecutor(runtime, {
1607
+ logger: createMockLogger(),
1608
+ });
1609
+ const result = await executor.execute({
1610
+ agent: createTestAgent({ name: "expiry-test-agent" }),
1611
+ prompt: "Test prompt",
1612
+ stateDir,
1613
+ resume: "old-expired-session", // Try to resume expired session
1614
+ });
1615
+ expect(result.success).toBe(true);
1616
+ // Should NOT have passed resume option since session was expired
1617
+ expect(receivedOptions?.resume).toBeUndefined();
1618
+ // New session should be created
1619
+ expect(result.sessionId).toBe("new-fresh-session");
1620
+ });
1621
+ it("resumes valid session that is not expired", async () => {
1622
+ const sessionsDir = join(stateDir, "sessions");
1623
+ // Create a valid session (last used 1 hour ago, default timeout is 24h)
1624
+ await updateSessionInfo(sessionsDir, "valid-session-agent", {
1625
+ session_id: "valid-session-id",
1626
+ mode: "autonomous",
1627
+ });
1628
+ let receivedOptions;
1629
+ const runtime = createMockRuntime(async function* (options) {
1630
+ receivedOptions = options;
1631
+ yield { type: "assistant", content: "Resumed successfully" };
1632
+ });
1633
+ const executor = new JobExecutor(runtime, {
1634
+ logger: createMockLogger(),
1635
+ });
1636
+ await executor.execute({
1637
+ agent: createTestAgent({ name: "valid-session-agent" }),
1638
+ prompt: "Test prompt",
1639
+ stateDir,
1640
+ resume: "valid-session-id",
1641
+ });
1642
+ // Should have passed resume option since session is valid
1643
+ expect(receivedOptions?.resume).toBe("valid-session-id");
1644
+ });
1645
+ it("uses stored session_id from disk, not the options.resume value", async () => {
1646
+ const sessionsDir = join(stateDir, "sessions");
1647
+ // Create a valid session with a DIFFERENT session_id than what options.resume will provide
1648
+ // This simulates the case where the stored session has been updated but the caller
1649
+ // is using an outdated or generic resume value (like a boolean or old session ID)
1650
+ await updateSessionInfo(sessionsDir, "session-mismatch-agent", {
1651
+ session_id: "stored-session-abc123", // The actual session ID stored on disk
1652
+ mode: "autonomous",
1653
+ });
1654
+ let receivedOptions;
1655
+ const runtime = createMockRuntime(async function* (options) {
1656
+ receivedOptions = options;
1657
+ yield { type: "assistant", content: "Resumed with correct session" };
1658
+ });
1659
+ const executor = new JobExecutor(runtime, {
1660
+ logger: createMockLogger(),
1661
+ });
1662
+ await executor.execute({
1663
+ agent: createTestAgent({ name: "session-mismatch-agent" }),
1664
+ prompt: "Test prompt",
1665
+ stateDir,
1666
+ // Pass a DIFFERENT value than what's stored - this could be an old session ID
1667
+ // or any truthy value indicating "please resume"
1668
+ resume: "outdated-session-xyz789",
1669
+ });
1670
+ // CRITICAL: Should use the session_id from the stored session file, NOT the options.resume value
1671
+ // This is the fix for the unexpected logout bug - we must use the actual stored session ID
1672
+ expect(receivedOptions?.resume).toBe("stored-session-abc123");
1673
+ });
1674
+ it("respects custom session timeout from agent config", async () => {
1675
+ const sessionsDir = join(stateDir, "sessions");
1676
+ // Create a session that is 2 hours old
1677
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
1678
+ const { writeFile } = await import("node:fs/promises");
1679
+ const sessionPath = join(sessionsDir, "custom-timeout-agent.json");
1680
+ const sessionData = {
1681
+ agent_name: "custom-timeout-agent",
1682
+ session_id: "two-hour-old-session",
1683
+ created_at: twoHoursAgo,
1684
+ last_used_at: twoHoursAgo,
1685
+ job_count: 1,
1686
+ mode: "autonomous",
1687
+ };
1688
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1689
+ let receivedOptions;
1690
+ const runtime = createMockRuntime(async function* (options) {
1691
+ receivedOptions = options;
1692
+ yield { type: "assistant", content: "Done" };
1693
+ });
1694
+ const executor = new JobExecutor(runtime, {
1695
+ logger: createMockLogger(),
1696
+ });
1697
+ // With 1 hour timeout, 2-hour-old session should be expired
1698
+ await executor.execute({
1699
+ agent: createTestAgent({
1700
+ name: "custom-timeout-agent",
1701
+ session: { timeout: "1h" },
1702
+ }),
1703
+ prompt: "Test prompt",
1704
+ stateDir,
1705
+ resume: "two-hour-old-session",
1706
+ });
1707
+ // Should NOT have passed resume since session exceeded custom timeout
1708
+ expect(receivedOptions?.resume).toBeUndefined();
1709
+ });
1710
+ it("writes system message to job output when session expires", async () => {
1711
+ const sessionsDir = join(stateDir, "sessions");
1712
+ // Create an expired session
1713
+ const expiredLastUsed = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
1714
+ const { writeFile } = await import("node:fs/promises");
1715
+ const sessionPath = join(sessionsDir, "output-test-agent.json");
1716
+ const sessionData = {
1717
+ agent_name: "output-test-agent",
1718
+ session_id: "expired-session",
1719
+ created_at: expiredLastUsed,
1720
+ last_used_at: expiredLastUsed,
1721
+ job_count: 1,
1722
+ mode: "autonomous",
1723
+ };
1724
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1725
+ const runtime = createMockRuntime(async function* () {
1726
+ yield { type: "assistant", content: "Fresh start" };
1727
+ });
1728
+ const executor = new JobExecutor(runtime, {
1729
+ logger: createMockLogger(),
1730
+ });
1731
+ const result = await executor.execute({
1732
+ agent: createTestAgent({ name: "output-test-agent" }),
1733
+ prompt: "Test prompt",
1734
+ stateDir,
1735
+ resume: "expired-session",
1736
+ });
1737
+ // Check job output for session expiry message
1738
+ const output = await readJobOutputAll(join(stateDir, "jobs"), result.jobId);
1739
+ const systemMsg = output.find((m) => m.type === "system" && m.content?.includes("session"));
1740
+ expect(systemMsg).toBeDefined();
1741
+ });
1742
+ it("clears expired session file when detected", async () => {
1743
+ const sessionsDir = join(stateDir, "sessions");
1744
+ // Create an expired session
1745
+ const expiredLastUsed = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
1746
+ const { writeFile } = await import("node:fs/promises");
1747
+ const sessionPath = join(sessionsDir, "clear-test-agent.json");
1748
+ const sessionData = {
1749
+ agent_name: "clear-test-agent",
1750
+ session_id: "expired-to-clear",
1751
+ created_at: expiredLastUsed,
1752
+ last_used_at: expiredLastUsed,
1753
+ job_count: 1,
1754
+ mode: "autonomous",
1755
+ };
1756
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1757
+ // Verify session exists
1758
+ let sessionBefore = await getSessionInfo(sessionsDir, "clear-test-agent");
1759
+ expect(sessionBefore).not.toBeNull();
1760
+ const runtime = createMockRuntime(async function* () {
1761
+ yield { type: "system", content: "Init", subtype: "init", session_id: "new-session" };
1762
+ yield { type: "assistant", content: "Fresh" };
1763
+ });
1764
+ const executor = new JobExecutor(runtime, {
1765
+ logger: createMockLogger(),
1766
+ });
1767
+ await executor.execute({
1768
+ agent: createTestAgent({ name: "clear-test-agent" }),
1769
+ prompt: "Test prompt",
1770
+ stateDir,
1771
+ resume: "expired-to-clear",
1772
+ });
1773
+ // Session should now have the new ID (old one was cleared and new one created)
1774
+ const sessionAfter = await getSessionInfo(sessionsDir, "clear-test-agent");
1775
+ expect(sessionAfter?.session_id).toBe("new-session");
1776
+ });
1777
+ it("retries with fresh session when server-side session expiration detected", async () => {
1778
+ const sessionsDir = join(stateDir, "sessions");
1779
+ // Create a valid session (not locally expired)
1780
+ await updateSessionInfo(sessionsDir, "server-expiry-agent", {
1781
+ session_id: "valid-local-session",
1782
+ mode: "autonomous",
1783
+ });
1784
+ let attemptCount = 0;
1785
+ let lastResumeValue;
1786
+ // First attempt throws session expired error, second succeeds
1787
+ const runtime = createMockRuntime(async function* (options) {
1788
+ attemptCount++;
1789
+ lastResumeValue = options.resume;
1790
+ if (attemptCount === 1 && options.resume) {
1791
+ // First attempt with resume - server says session expired
1792
+ throw new Error("Session expired on server");
1793
+ }
1794
+ // Second attempt or fresh session - succeed
1795
+ yield { type: "system", content: "Init", subtype: "init", session_id: "new-server-session" };
1796
+ yield { type: "assistant", content: "Success after retry" };
1797
+ });
1798
+ const executor = new JobExecutor(runtime, {
1799
+ logger: createMockLogger(),
1800
+ });
1801
+ const result = await executor.execute({
1802
+ agent: createTestAgent({ name: "server-expiry-agent" }),
1803
+ prompt: "Test prompt",
1804
+ stateDir,
1805
+ resume: "valid-local-session",
1806
+ });
1807
+ // Should have succeeded after retry
1808
+ expect(result.success).toBe(true);
1809
+ expect(result.sessionId).toBe("new-server-session");
1810
+ // Should have attempted twice: first with resume, then fresh
1811
+ expect(attemptCount).toBe(2);
1812
+ // Second attempt should NOT have resume (fresh session)
1813
+ expect(lastResumeValue).toBeUndefined();
1814
+ // Check job output includes retry message
1815
+ const output = await readJobOutputAll(join(stateDir, "jobs"), result.jobId);
1816
+ const retryMsg = output.find((m) => m.type === "system" && m.content?.includes("Retrying with fresh session"));
1817
+ expect(retryMsg).toBeDefined();
1818
+ });
1819
+ it("does not retry infinitely on persistent server errors", async () => {
1820
+ const sessionsDir = join(stateDir, "sessions");
1821
+ await updateSessionInfo(sessionsDir, "no-infinite-retry-agent", {
1822
+ session_id: "some-session",
1823
+ mode: "autonomous",
1824
+ });
1825
+ let attemptCount = 0;
1826
+ // Always throw session expired error
1827
+ const runtime = createMockRuntime(async function* (options) {
1828
+ attemptCount++;
1829
+ throw new Error("Session expired on server");
1830
+ });
1831
+ const executor = new JobExecutor(runtime, {
1832
+ logger: createMockLogger(),
1833
+ });
1834
+ const result = await executor.execute({
1835
+ agent: createTestAgent({ name: "no-infinite-retry-agent" }),
1836
+ prompt: "Test prompt",
1837
+ stateDir,
1838
+ resume: "some-session",
1839
+ });
1840
+ // Should fail after at most 2 attempts (initial + 1 retry)
1841
+ expect(result.success).toBe(false);
1842
+ expect(attemptCount).toBeLessThanOrEqual(2);
1843
+ expect(result.error?.message).toContain("Session expired");
1844
+ });
1845
+ it("updates last_used_at before execution to prevent mid-job session expiry", async () => {
1846
+ const sessionsDir = join(stateDir, "sessions");
1847
+ // Create a session that is 23 hours old (close to default 24h expiry)
1848
+ const twentyThreeHoursAgo = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString();
1849
+ const { writeFile } = await import("node:fs/promises");
1850
+ const sessionPath = join(sessionsDir, "refresh-test-agent.json");
1851
+ const sessionData = {
1852
+ agent_name: "refresh-test-agent",
1853
+ session_id: "almost-expired-session",
1854
+ created_at: twentyThreeHoursAgo,
1855
+ last_used_at: twentyThreeHoursAgo,
1856
+ job_count: 5,
1857
+ mode: "autonomous",
1858
+ };
1859
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1860
+ // Record when the test starts
1861
+ const testStartTime = Date.now();
1862
+ const runtime = createMockRuntime(async function* () {
1863
+ // Simulate a delay to ensure last_used_at was updated BEFORE we got here
1864
+ yield { type: "assistant", content: "Working..." };
1865
+ });
1866
+ const executor = new JobExecutor(runtime, {
1867
+ logger: createMockLogger(),
1868
+ });
1869
+ await executor.execute({
1870
+ agent: createTestAgent({ name: "refresh-test-agent" }),
1871
+ prompt: "Test prompt",
1872
+ stateDir,
1873
+ resume: "almost-expired-session",
1874
+ });
1875
+ // Check that last_used_at was updated to a recent time (not 23 hours ago)
1876
+ const sessionAfter = await getSessionInfo(sessionsDir, "refresh-test-agent");
1877
+ expect(sessionAfter).not.toBeNull();
1878
+ const lastUsedMs = new Date(sessionAfter.last_used_at).getTime();
1879
+ // Should be updated to approximately now (within last few seconds)
1880
+ expect(lastUsedMs).toBeGreaterThanOrEqual(testStartTime - 1000);
1881
+ // And not still 23 hours ago
1882
+ expect(lastUsedMs).toBeGreaterThan(new Date(twentyThreeHoursAgo).getTime());
1883
+ });
1884
+ });
1550
1885
  //# sourceMappingURL=job-executor.test.js.map