@stigmer/runner 3.0.8-dev.20260613085218 → 3.0.8

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 (94) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/hook-script.d.ts +23 -12
  3. package/dist/activities/execute-cursor/hook-script.js +85 -51
  4. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  5. package/dist/activities/execute-cursor/index.js +210 -79
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/message-translator.d.ts +35 -0
  8. package/dist/activities/execute-cursor/message-translator.js +114 -6
  9. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
  11. package/dist/activities/execute-cursor/prompt-builder.js +54 -0
  12. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  13. package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
  14. package/dist/activities/execute-cursor/workspace-setup.js +62 -30
  15. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/index.js +14 -4
  17. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
  19. package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
  20. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/status-builder.js +4 -5
  22. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/streaming-v3.js +3 -4
  24. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/streaming.d.ts +8 -0
  26. package/dist/activities/execute-deep-agent/streaming.js +3 -4
  27. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
  29. package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
  31. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  32. package/dist/config.d.ts +21 -0
  33. package/dist/config.js +12 -0
  34. package/dist/config.js.map +1 -1
  35. package/dist/in-flight.d.ts +35 -0
  36. package/dist/in-flight.js +61 -0
  37. package/dist/in-flight.js.map +1 -0
  38. package/dist/runner-manager.d.ts +2 -0
  39. package/dist/runner-manager.js +90 -29
  40. package/dist/runner-manager.js.map +1 -1
  41. package/dist/runner.d.ts +2 -0
  42. package/dist/runner.js +2 -0
  43. package/dist/runner.js.map +1 -1
  44. package/dist/shared/grpc-retry.d.ts +9 -20
  45. package/dist/shared/grpc-retry.js +9 -52
  46. package/dist/shared/grpc-retry.js.map +1 -1
  47. package/dist/shared/stall-watchdog.d.ts +68 -0
  48. package/dist/shared/stall-watchdog.js +102 -0
  49. package/dist/shared/stall-watchdog.js.map +1 -0
  50. package/dist/shared/status-offload.d.ts +84 -0
  51. package/dist/shared/status-offload.js +292 -0
  52. package/dist/shared/status-offload.js.map +1 -0
  53. package/dist/shared/status.d.ts +34 -3
  54. package/dist/shared/status.js +102 -9
  55. package/dist/shared/status.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/__tests__/config.test.ts +8 -0
  58. package/src/__tests__/in-flight.test.ts +84 -0
  59. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  60. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  61. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
  62. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
  63. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
  64. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
  65. package/src/activities/execute-cursor/hook-script.ts +85 -51
  66. package/src/activities/execute-cursor/index.ts +170 -35
  67. package/src/activities/execute-cursor/message-translator.ts +113 -6
  68. package/src/activities/execute-cursor/prompt-builder.ts +59 -0
  69. package/src/activities/execute-cursor/workspace-setup.ts +76 -44
  70. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  71. package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
  72. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
  73. package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
  74. package/src/activities/execute-deep-agent/index.ts +16 -4
  75. package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
  76. package/src/activities/execute-deep-agent/status-builder.ts +3 -5
  77. package/src/activities/execute-deep-agent/streaming-v3.ts +4 -4
  78. package/src/activities/execute-deep-agent/streaming.ts +13 -4
  79. package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
  80. package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
  81. package/src/config.ts +27 -0
  82. package/src/in-flight.ts +71 -0
  83. package/src/runner-manager.ts +127 -33
  84. package/src/runner.ts +6 -0
  85. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  86. package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
  87. package/src/shared/__tests__/grpc-retry.test.ts +5 -134
  88. package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
  89. package/src/shared/__tests__/status-offload.test.ts +256 -0
  90. package/src/shared/__tests__/status.test.ts +199 -0
  91. package/src/shared/grpc-retry.ts +9 -72
  92. package/src/shared/stall-watchdog.ts +122 -0
  93. package/src/shared/status-offload.ts +342 -0
  94. package/src/shared/status.ts +142 -8
@@ -1,30 +1,13 @@
1
1
  /**
2
- * Extended gRPC retry tests — ported from Python test_grpc_retry.py.
3
- *
4
- * Covers config validation, backoff timing, INTERNAL error handling,
5
- * mixed error sequences, and max-retries edge cases beyond the core
6
- * 15 tests in grpc-retry.test.ts.
2
+ * Extended gRPC error-classification tests — ported from Python
3
+ * test_grpc_retry.py. Covers additional status codes beyond the core
4
+ * grpc-retry.test.ts. The persist-with-backoff loop that consumes these
5
+ * classifiers is tested in status.test.ts ("persistStatus transient retry").
7
6
  */
8
7
 
9
- import { describe, it, expect, vi } from "vitest";
8
+ import { describe, it, expect } from "vitest";
10
9
  import { ConnectError, Code } from "@connectrpc/connect";
11
- import { create } from "@bufbuild/protobuf";
12
- import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
13
- import { ExecutionControlSignal } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
14
- import { persistWithRetry, isRetryableError, isTerminalError } from "../grpc-retry.js";
15
- import type { StigmerClient } from "../../client/stigmer-client.js";
16
-
17
- function makeStatus() {
18
- return create(AgentExecutionStatusSchema, {});
19
- }
20
-
21
- function mockClient(
22
- impl: (...args: unknown[]) => Promise<{ signal: ExecutionControlSignal }>,
23
- ): StigmerClient {
24
- return { updateStatus: impl } as unknown as StigmerClient;
25
- }
26
-
27
- const noDelay = async () => {};
10
+ import { isRetryableError, isTerminalError } from "../grpc-retry.js";
28
11
 
29
12
  describe("isRetryableError — extended codes", () => {
30
13
  it("returns false for INTERNAL", () => {
@@ -53,124 +36,3 @@ describe("isTerminalError — extended codes", () => {
53
36
  expect(isTerminalError(new Error("random"))).toBe(false);
54
37
  });
55
38
  });
56
-
57
- describe("persistWithRetry — extended scenarios", () => {
58
- it("does not retry INTERNAL errors (neither retryable nor terminal)", async () => {
59
- let attempt = 0;
60
- const client = mockClient(async () => {
61
- attempt++;
62
- throw new ConnectError("internal", Code.Internal);
63
- });
64
-
65
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
66
- await persistWithRetry(client, "exec-int", makeStatus(), {
67
- maxRetries: 3,
68
- delayFn: noDelay,
69
- });
70
- errorSpy.mockRestore();
71
-
72
- expect(attempt).toBe(1);
73
- });
74
-
75
- it("does not retry ALREADY_EXISTS", async () => {
76
- let attempt = 0;
77
- const client = mockClient(async () => {
78
- attempt++;
79
- throw new ConnectError("exists", Code.AlreadyExists);
80
- });
81
-
82
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
83
- await persistWithRetry(client, "exec-ae", makeStatus(), {
84
- maxRetries: 3,
85
- delayFn: noDelay,
86
- });
87
- errorSpy.mockRestore();
88
-
89
- expect(attempt).toBe(1);
90
- });
91
-
92
- it("handles mixed error sequence: retryable then terminal", async () => {
93
- let attempt = 0;
94
- const client = mockClient(async () => {
95
- attempt++;
96
- if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
97
- throw new ConnectError("gone", Code.NotFound);
98
- });
99
-
100
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
101
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
102
- const signal = await persistWithRetry(client, "exec-mix", makeStatus(), {
103
- delayFn: noDelay,
104
- });
105
- warnSpy.mockRestore();
106
- errorSpy.mockRestore();
107
-
108
- expect(attempt).toBe(2);
109
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
110
- });
111
-
112
- it("handles retryable then success", async () => {
113
- let attempt = 0;
114
- const client = mockClient(async () => {
115
- attempt++;
116
- if (attempt <= 2) throw new ConnectError("timeout", Code.DeadlineExceeded);
117
- return { signal: ExecutionControlSignal.STOP };
118
- });
119
-
120
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
121
- const signal = await persistWithRetry(client, "exec-ret", makeStatus(), {
122
- maxRetries: 5,
123
- delayFn: noDelay,
124
- });
125
- warnSpy.mockRestore();
126
-
127
- expect(signal).toBe(ExecutionControlSignal.STOP);
128
- expect(attempt).toBe(3);
129
- });
130
-
131
- it("maxRetries = 0 means one attempt only", async () => {
132
- let attempt = 0;
133
- const client = mockClient(async () => {
134
- attempt++;
135
- throw new ConnectError("down", Code.Unavailable);
136
- });
137
-
138
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
139
- await persistWithRetry(client, "exec-0", makeStatus(), {
140
- maxRetries: 0,
141
- delayFn: noDelay,
142
- });
143
- errorSpy.mockRestore();
144
-
145
- expect(attempt).toBe(1);
146
- });
147
-
148
- it("respects custom backoff factor", async () => {
149
- const delays: number[] = [];
150
- const client = mockClient(async () => {
151
- throw new ConnectError("down", Code.Unavailable);
152
- });
153
-
154
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
155
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
156
- await persistWithRetry(client, "exec-bf", makeStatus(), {
157
- baseDelayMs: 50,
158
- backoffFactor: 3,
159
- maxRetries: 3,
160
- delayFn: async (ms) => { delays.push(ms); },
161
- });
162
- warnSpy.mockRestore();
163
- errorSpy.mockRestore();
164
-
165
- expect(delays).toEqual([50, 150, 450]);
166
- });
167
-
168
- it("uses default options when none provided", async () => {
169
- const client = mockClient(async () => ({
170
- signal: ExecutionControlSignal.UNSPECIFIED,
171
- }));
172
-
173
- const signal = await persistWithRetry(client, "exec-default", makeStatus());
174
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
175
- });
176
- });
@@ -1,26 +1,10 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { describe, it, expect } from "vitest";
2
2
  import { ConnectError, Code } from "@connectrpc/connect";
3
- import { create } from "@bufbuild/protobuf";
4
- import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
- import { ExecutionControlSignal } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
6
- import {
7
- persistWithRetry,
8
- isRetryableError,
9
- isTerminalError,
10
- } from "../grpc-retry.js";
11
- import type { StigmerClient } from "../../client/stigmer-client.js";
3
+ import { isRetryableError, isTerminalError } from "../grpc-retry.js";
12
4
 
13
- function makeStatus() {
14
- return create(AgentExecutionStatusSchema, {});
15
- }
16
-
17
- function mockClient(
18
- updateStatusImpl: (...args: unknown[]) => Promise<{ signal: ExecutionControlSignal }>,
19
- ): StigmerClient {
20
- return { updateStatus: updateStatusImpl } as unknown as StigmerClient;
21
- }
22
-
23
- const noDelay = async () => {};
5
+ // The persist-with-backoff loop that consumes these classifiers lives in
6
+ // status.ts and is exercised by status.test.ts ("persistStatus — transient
7
+ // retry"). This file covers the classification policy in isolation.
24
8
 
25
9
  describe("isRetryableError", () => {
26
10
  it("returns true for UNAVAILABLE", () => {
@@ -57,116 +41,3 @@ describe("isTerminalError", () => {
57
41
  expect(isTerminalError(new ConnectError("down", Code.Unavailable))).toBe(false);
58
42
  });
59
43
  });
60
-
61
- describe("persistWithRetry", () => {
62
- it("returns signal on successful first attempt", async () => {
63
- const client = mockClient(async () => ({
64
- signal: ExecutionControlSignal.STOP,
65
- }));
66
-
67
- const signal = await persistWithRetry(client, "exec-1", makeStatus(), {
68
- delayFn: noDelay,
69
- });
70
-
71
- expect(signal).toBe(ExecutionControlSignal.STOP);
72
- });
73
-
74
- it("retries on UNAVAILABLE and succeeds", async () => {
75
- let attempt = 0;
76
- const client = mockClient(async () => {
77
- attempt++;
78
- if (attempt === 1) throw new ConnectError("down", Code.Unavailable);
79
- return { signal: ExecutionControlSignal.UNSPECIFIED };
80
- });
81
-
82
- const signal = await persistWithRetry(client, "exec-2", makeStatus(), {
83
- delayFn: noDelay,
84
- });
85
-
86
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
87
- expect(attempt).toBe(2);
88
- });
89
-
90
- it("does not retry terminal errors (NOT_FOUND)", async () => {
91
- let attempt = 0;
92
- const client = mockClient(async () => {
93
- attempt++;
94
- throw new ConnectError("gone", Code.NotFound);
95
- });
96
-
97
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
98
- const signal = await persistWithRetry(client, "exec-3", makeStatus(), {
99
- delayFn: noDelay,
100
- });
101
- errorSpy.mockRestore();
102
-
103
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
104
- expect(attempt).toBe(1);
105
- });
106
-
107
- it("returns UNSPECIFIED after all retries exhausted", async () => {
108
- const client = mockClient(async () => {
109
- throw new ConnectError("down", Code.Unavailable);
110
- });
111
-
112
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
113
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
114
- const signal = await persistWithRetry(client, "exec-4", makeStatus(), {
115
- maxRetries: 3,
116
- delayFn: noDelay,
117
- });
118
- warnSpy.mockRestore();
119
- errorSpy.mockRestore();
120
-
121
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
122
- });
123
-
124
- it("uses correct backoff delays", async () => {
125
- const delays: number[] = [];
126
- const client = mockClient(async () => {
127
- throw new ConnectError("down", Code.Unavailable);
128
- });
129
-
130
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
131
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
132
- await persistWithRetry(client, "exec-5", makeStatus(), {
133
- baseDelayMs: 100,
134
- backoffFactor: 2,
135
- maxRetries: 3,
136
- delayFn: async (ms) => { delays.push(ms); },
137
- });
138
- warnSpy.mockRestore();
139
- errorSpy.mockRestore();
140
-
141
- expect(delays).toEqual([100, 200, 400]);
142
- });
143
-
144
- it("handles non-ConnectError by not retrying", async () => {
145
- let attempt = 0;
146
- const client = mockClient(async () => {
147
- attempt++;
148
- throw new Error("unexpected");
149
- });
150
-
151
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
152
- const signal = await persistWithRetry(client, "exec-6", makeStatus(), {
153
- delayFn: noDelay,
154
- });
155
- errorSpy.mockRestore();
156
-
157
- expect(signal).toBe(ExecutionControlSignal.UNSPECIFIED);
158
- expect(attempt).toBe(1);
159
- });
160
-
161
- it("never throws regardless of error type", async () => {
162
- const client = mockClient(async () => {
163
- throw new ConnectError("server exploded", Code.Internal);
164
- });
165
-
166
- const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
167
- await expect(
168
- persistWithRetry(client, "exec-7", makeStatus(), { delayFn: noDelay }),
169
- ).resolves.toBe(ExecutionControlSignal.UNSPECIFIED);
170
- errorSpy.mockRestore();
171
- });
172
- });
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ startStallWatchdog,
4
+ StallTimeoutError,
5
+ formatStallFailure,
6
+ STALL_ERROR_PREFIX,
7
+ DEFAULT_STALL_TIMEOUT_MS,
8
+ } from "../stall-watchdog.js";
9
+
10
+ describe("startStallWatchdog", () => {
11
+ beforeEach(() => {
12
+ vi.useFakeTimers();
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ it("fires onStall once after the idle window elapses with no activity", () => {
21
+ const onStall = vi.fn();
22
+ const wd = startStallWatchdog(60_000, onStall);
23
+
24
+ // Just before the window: not yet fired.
25
+ vi.advanceTimersByTime(59_000);
26
+ expect(onStall).not.toHaveBeenCalled();
27
+
28
+ // Cross the window: fires exactly once.
29
+ vi.advanceTimersByTime(2_000);
30
+ expect(onStall).toHaveBeenCalledTimes(1);
31
+ expect(onStall.mock.calls[0][0]).toBeGreaterThanOrEqual(60_000);
32
+
33
+ // It self-disarms — no further fires even as time marches on.
34
+ vi.advanceTimersByTime(120_000);
35
+ expect(onStall).toHaveBeenCalledTimes(1);
36
+
37
+ wd.stop();
38
+ });
39
+
40
+ it("recordActivity resets the idle timer so progress keeps it alive", () => {
41
+ const onStall = vi.fn();
42
+ const wd = startStallWatchdog(60_000, onStall);
43
+
44
+ // Report progress every 40s for a while: the window never elapses.
45
+ for (let i = 0; i < 10; i++) {
46
+ vi.advanceTimersByTime(40_000);
47
+ wd.recordActivity();
48
+ }
49
+ expect(onStall).not.toHaveBeenCalled();
50
+
51
+ // Now go quiet past the window. Detection has up to one poll interval
52
+ // (stallMs/4, capped 15s) of slop because ticks fire on absolute
53
+ // boundaries, so advance stallMs + a full tick to guarantee a fire.
54
+ vi.advanceTimersByTime(76_000);
55
+ expect(onStall).toHaveBeenCalledTimes(1);
56
+
57
+ wd.stop();
58
+ });
59
+
60
+ it("stop() disarms the watchdog so onStall never fires", () => {
61
+ const onStall = vi.fn();
62
+ const wd = startStallWatchdog(60_000, onStall);
63
+
64
+ wd.stop();
65
+ vi.advanceTimersByTime(600_000);
66
+ expect(onStall).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("is robust to stop() called after firing (idempotent)", () => {
70
+ const onStall = vi.fn();
71
+ const wd = startStallWatchdog(30_000, onStall);
72
+
73
+ vi.advanceTimersByTime(31_000);
74
+ expect(onStall).toHaveBeenCalledTimes(1);
75
+
76
+ expect(() => wd.stop()).not.toThrow();
77
+ vi.advanceTimersByTime(60_000);
78
+ expect(onStall).toHaveBeenCalledTimes(1);
79
+ });
80
+ });
81
+
82
+ describe("StallTimeoutError", () => {
83
+ it("builds a readable message from the idle duration", () => {
84
+ const err = new StallTimeoutError(120_000);
85
+ expect(err.name).toBe("StallTimeoutError");
86
+ expect(err.stalledMs).toBe(120_000);
87
+ expect(err.message).toContain("120s");
88
+ });
89
+
90
+ it("appends an optional detail", () => {
91
+ const err = new StallTimeoutError(90_000, "last tool: browser_capture");
92
+ expect(err.message).toContain("90s");
93
+ expect(err.message).toContain("last tool: browser_capture");
94
+ });
95
+ });
96
+
97
+ describe("formatStallFailure", () => {
98
+ it("prefixes the recognizable tag and appends actionable guidance", () => {
99
+ const msg = formatStallFailure(new StallTimeoutError(180_000, "last tool: browser_capture"));
100
+ expect(msg.startsWith(STALL_ERROR_PREFIX)).toBe(true);
101
+ expect(msg).toContain("180s");
102
+ expect(msg).toContain("last tool: browser_capture");
103
+ expect(msg).toContain("Retry or resume.");
104
+ });
105
+ });
106
+
107
+ describe("DEFAULT_STALL_TIMEOUT_MS", () => {
108
+ it("matches the canonical 120s default", () => {
109
+ expect(DEFAULT_STALL_TIMEOUT_MS).toBe(120_000);
110
+ });
111
+ });
112
+
113
+ /**
114
+ * Integration contract for how execute-cursor/index.ts wires the watchdog to
115
+ * the SDK Run: on stall it must (1) record the stall, (2) build a
116
+ * StallTimeoutError, and (3) cancel the run when the SDK supports cancel —
117
+ * exactly the onStall closure in index.ts. We assert that contract here against
118
+ * a faithful fake Run rather than standing up the full executeCursor activity
119
+ * (which has no end-to-end test harness), keeping the test focused and robust.
120
+ */
121
+ describe("stall -> run.cancel() wiring contract (mirrors execute-cursor/index.ts)", () => {
122
+ beforeEach(() => vi.useFakeTimers());
123
+ afterEach(() => {
124
+ vi.restoreAllMocks();
125
+ vi.useRealTimers();
126
+ });
127
+
128
+ interface FakeRun {
129
+ supports: (op: string) => boolean;
130
+ cancel: () => Promise<void>;
131
+ }
132
+
133
+ function wire(run: FakeRun, stallMs: number) {
134
+ const state: { stallDetected: boolean; stallError?: StallTimeoutError } = {
135
+ stallDetected: false,
136
+ };
137
+ let lastToolName: string | undefined;
138
+ const wd = startStallWatchdog(stallMs, (idleMs) => {
139
+ state.stallDetected = true;
140
+ state.stallError = new StallTimeoutError(idleMs, lastToolName ? `last tool: ${lastToolName}` : undefined);
141
+ if (run.supports?.("cancel")) void run.cancel();
142
+ });
143
+ return {
144
+ state,
145
+ recordActivity: (tool?: string) => {
146
+ if (tool) lastToolName = tool;
147
+ wd.recordActivity();
148
+ },
149
+ stop: () => wd.stop(),
150
+ };
151
+ }
152
+
153
+ it("cancels the run and records a StallTimeoutError when the stream wedges", () => {
154
+ const cancel = vi.fn().mockResolvedValue(undefined);
155
+ const run: FakeRun = { supports: () => true, cancel };
156
+ const w = wire(run, 60_000);
157
+
158
+ // Simulate a tool call starting, then no further progress.
159
+ w.recordActivity("browser_capture");
160
+ vi.advanceTimersByTime(61_000);
161
+
162
+ expect(w.state.stallDetected).toBe(true);
163
+ expect(cancel).toHaveBeenCalledTimes(1);
164
+ expect(w.state.stallError).toBeInstanceOf(StallTimeoutError);
165
+ expect(formatStallFailure(w.state.stallError!)).toContain("last tool: browser_capture");
166
+ w.stop();
167
+ });
168
+
169
+ it("does not call cancel when the SDK does not support it", () => {
170
+ const cancel = vi.fn().mockResolvedValue(undefined);
171
+ const run: FakeRun = { supports: () => false, cancel };
172
+ const w = wire(run, 30_000);
173
+
174
+ vi.advanceTimersByTime(31_000);
175
+ expect(w.state.stallDetected).toBe(true);
176
+ expect(cancel).not.toHaveBeenCalled();
177
+ w.stop();
178
+ });
179
+
180
+ it("a stream that keeps making progress never stalls or cancels", () => {
181
+ const cancel = vi.fn().mockResolvedValue(undefined);
182
+ const run: FakeRun = { supports: () => true, cancel };
183
+ const w = wire(run, 60_000);
184
+
185
+ for (let i = 0; i < 20; i++) {
186
+ vi.advanceTimersByTime(30_000);
187
+ w.recordActivity("scroll");
188
+ }
189
+ expect(w.state.stallDetected).toBe(false);
190
+ expect(cancel).not.toHaveBeenCalled();
191
+ w.stop();
192
+ });
193
+ });