@stigmer/runner 3.14.0 → 3.14.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 (56) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__test-utils__/hermetic-activity.d.ts +245 -0
  3. package/dist/__test-utils__/hermetic-activity.js +369 -0
  4. package/dist/__test-utils__/hermetic-activity.js.map +1 -0
  5. package/dist/__test-utils__/mock-client.d.ts +13 -0
  6. package/dist/__test-utils__/mock-client.js +45 -0
  7. package/dist/__test-utils__/mock-client.js.map +1 -0
  8. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +172 -0
  9. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +331 -0
  10. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -0
  11. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +167 -0
  12. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +239 -0
  13. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -0
  14. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +97 -0
  15. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +132 -0
  16. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -0
  17. package/dist/harness/capabilities.d.ts +71 -0
  18. package/dist/harness/capabilities.js +36 -0
  19. package/dist/harness/capabilities.js.map +1 -0
  20. package/dist/harness/registry.d.ts +67 -0
  21. package/dist/harness/registry.js +112 -0
  22. package/dist/harness/registry.js.map +1 -0
  23. package/dist/harness/types.d.ts +268 -0
  24. package/dist/harness/types.js +55 -0
  25. package/dist/harness/types.js.map +1 -0
  26. package/package.json +4 -4
  27. package/src/__test-utils__/__tests__/harness-contract-self-check.test.ts +229 -0
  28. package/src/__test-utils__/config-fixture.ts +63 -0
  29. package/src/__test-utils__/harness-contract/contract.ts +536 -0
  30. package/src/__test-utils__/harness-contract/recording-sink.ts +96 -0
  31. package/src/__test-utils__/harness-contract/scripted-adapter.ts +289 -0
  32. package/src/__test-utils__/harness-contract/types.ts +100 -0
  33. package/src/__test-utils__/hermetic-activity.ts +477 -0
  34. package/src/__test-utils__/proto-helpers.ts +25 -0
  35. package/src/__tests__/harness-contract.test.ts +25 -0
  36. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +422 -0
  37. package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +342 -0
  38. package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +166 -0
  39. package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +228 -0
  40. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +180 -0
  41. package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn1.status.json +55 -0
  42. package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn2.status.json +77 -0
  43. package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +126 -0
  44. package/src/activities/execute-cursor/__tests__/hermetic/goldens/pause.status.json +45 -0
  45. package/src/activities/execute-cursor/__tests__/hermetic/goldens/plain-turn.status.json +48 -0
  46. package/src/activities/execute-cursor/__tests__/hermetic/goldens/recovery-fresh-agent.status.json +53 -0
  47. package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-call.status.json +68 -0
  48. package/src/activities/execute-cursor/__tests__/hermetic/goldens/worker-shutdown.status.json +47 -0
  49. package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +201 -0
  50. package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +171 -0
  51. package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +156 -0
  52. package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +137 -0
  53. package/src/harness/__tests__/registry.test.ts +167 -0
  54. package/src/harness/capabilities.ts +75 -0
  55. package/src/harness/registry.ts +123 -0
  56. package/src/harness/types.ts +278 -0
@@ -0,0 +1,45 @@
1
+ {
2
+ "messages": [
3
+ {
4
+ "type": "MESSAGE_AI",
5
+ "content": "Starting on the parser.",
6
+ "timestamp": "2026-01-01T00:00:02.000Z"
7
+ },
8
+ {
9
+ "type": "MESSAGE_SYSTEM",
10
+ "content": "Execution paused by user. Use resume to continue.",
11
+ "timestamp": "2026-01-01T00:00:04.000Z"
12
+ },
13
+ {
14
+ "type": "MESSAGE_SYSTEM",
15
+ "content": "Execution paused by user. Use resume to continue.",
16
+ "timestamp": "2026-01-01T00:00:04.000Z"
17
+ }
18
+ ],
19
+ "phase": "EXECUTION_PAUSED",
20
+ "startedAt": "2026-01-01T00:00:00.000Z",
21
+ "fileReviewEventStream": {
22
+ "executionId": "aex_hermetic_0001",
23
+ "events": [
24
+ {
25
+ "eventId": "aex_hermetic_0001:0:aex_hermetic_0001:0:FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
26
+ "changeSetId": "aex_hermetic_0001:0",
27
+ "eventType": "FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
28
+ "timestamp": "2026-01-01T00:00:00.000Z",
29
+ "actor": "runner",
30
+ "baselineCaptured": {
31
+ "changeSetId": "aex_hermetic_0001:0",
32
+ "turnId": "aex_hermetic_0001:0",
33
+ "harnessId": "cursor",
34
+ "baselineSnapshot": {
35
+ "kind": "SNAPSHOT_KIND_CAS_MANIFEST"
36
+ }
37
+ }
38
+ }
39
+ ]
40
+ },
41
+ "fileChangeProgress": {
42
+ "changeSetId": "aex_hermetic_0001:0",
43
+ "capturedAt": "2026-01-01T00:00:01.000Z"
44
+ }
45
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "messages": [
3
+ {
4
+ "type": "MESSAGE_AI",
5
+ "content": "Hello there, hermetic world.",
6
+ "timestamp": "2026-01-01T00:00:02.000Z"
7
+ }
8
+ ],
9
+ "phase": "EXECUTION_COMPLETED",
10
+ "startedAt": "2026-01-01T00:00:00.000Z",
11
+ "completedAt": "2026-01-01T00:00:04.000Z",
12
+ "streamingUsage": {
13
+ "inputTokens": "1200",
14
+ "outputTokens": "40",
15
+ "totalTokens": "1240",
16
+ "turnCount": 1,
17
+ "estimatedCostUsd": 0.00136,
18
+ "model": "composer-2.5",
19
+ "observedAt": "2026-01-01T00:00:03.000Z",
20
+ "requestedServiceTier": "SERVICE_TIER_STANDARD",
21
+ "requestedModelParams": "[{\"id\":\"fast\",\"value\":\"false\"},{\"id\":\"thinking\",\"value\":\"false\"}]",
22
+ "requestedThinkingMode": "THINKING_MODE_DISABLED"
23
+ },
24
+ "fileReviewEventStream": {
25
+ "executionId": "aex_hermetic_0001",
26
+ "events": [
27
+ {
28
+ "eventId": "aex_hermetic_0001:0:aex_hermetic_0001:0:FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
29
+ "changeSetId": "aex_hermetic_0001:0",
30
+ "eventType": "FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
31
+ "timestamp": "2026-01-01T00:00:00.000Z",
32
+ "actor": "runner",
33
+ "baselineCaptured": {
34
+ "changeSetId": "aex_hermetic_0001:0",
35
+ "turnId": "aex_hermetic_0001:0",
36
+ "harnessId": "cursor",
37
+ "baselineSnapshot": {
38
+ "kind": "SNAPSHOT_KIND_CAS_MANIFEST"
39
+ }
40
+ }
41
+ }
42
+ ]
43
+ },
44
+ "fileChangeProgress": {
45
+ "changeSetId": "aex_hermetic_0001:0",
46
+ "capturedAt": "2026-01-01T00:00:01.000Z"
47
+ }
48
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "messages": [
3
+ {
4
+ "type": "MESSAGE_AI",
5
+ "content": "Let me look at the recent changes.",
6
+ "timestamp": "2026-01-01T00:00:02.000Z"
7
+ },
8
+ {
9
+ "type": "MESSAGE_AI",
10
+ "content": "Two files changed since yesterday: notes.md and README.md.",
11
+ "timestamp": "2026-01-01T00:00:06.000Z"
12
+ }
13
+ ],
14
+ "phase": "EXECUTION_COMPLETED",
15
+ "startedAt": "2026-01-01T00:00:00.000Z",
16
+ "completedAt": "2026-01-01T00:00:08.000Z",
17
+ "streamingUsage": {
18
+ "inputTokens": "2800",
19
+ "outputTokens": "55",
20
+ "totalTokens": "2855",
21
+ "turnCount": 1,
22
+ "estimatedCostUsd": 0.00302,
23
+ "model": "composer-2.5",
24
+ "observedAt": "2026-01-01T00:00:07.000Z",
25
+ "requestedServiceTier": "SERVICE_TIER_STANDARD",
26
+ "requestedModelParams": "[{\"id\":\"fast\",\"value\":\"false\"},{\"id\":\"thinking\",\"value\":\"false\"}]",
27
+ "requestedThinkingMode": "THINKING_MODE_DISABLED"
28
+ },
29
+ "fileReviewEventStream": {
30
+ "executionId": "aex_hermetic_0001",
31
+ "events": [
32
+ {
33
+ "eventId": "aex_hermetic_0001:0:aex_hermetic_0001:0:FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
34
+ "changeSetId": "aex_hermetic_0001:0",
35
+ "eventType": "FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
36
+ "timestamp": "2026-01-01T00:00:00.000Z",
37
+ "actor": "runner",
38
+ "baselineCaptured": {
39
+ "changeSetId": "aex_hermetic_0001:0",
40
+ "turnId": "aex_hermetic_0001:0",
41
+ "harnessId": "cursor",
42
+ "baselineSnapshot": {
43
+ "kind": "SNAPSHOT_KIND_CAS_MANIFEST"
44
+ }
45
+ }
46
+ }
47
+ ]
48
+ },
49
+ "fileChangeProgress": {
50
+ "changeSetId": "aex_hermetic_0001:0",
51
+ "capturedAt": "2026-01-01T00:00:01.000Z"
52
+ }
53
+ }
@@ -0,0 +1,68 @@
1
+ {
2
+ "messages": [
3
+ {
4
+ "type": "MESSAGE_AI",
5
+ "content": "Let me read it.",
6
+ "timestamp": "2026-01-01T00:00:02.000Z",
7
+ "toolCalls": [
8
+ {
9
+ "id": "call-hermetic-read-0001",
10
+ "name": "read",
11
+ "args": {
12
+ "path": "README.md"
13
+ },
14
+ "result": "# Hermetic\n\nA fixture readme.\n",
15
+ "status": "TOOL_CALL_COMPLETED",
16
+ "startedAt": "2026-01-01T00:00:03.000Z",
17
+ "completedAt": "2026-01-01T00:00:04.000Z",
18
+ "argsPreview": "{\"path\":\"README.md\"}",
19
+ "toolKind": "TOOL_KIND_FILE_READ"
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "type": "MESSAGE_AI",
25
+ "content": "README.md holds a one-line fixture description.",
26
+ "timestamp": "2026-01-01T00:00:05.000Z"
27
+ }
28
+ ],
29
+ "phase": "EXECUTION_COMPLETED",
30
+ "startedAt": "2026-01-01T00:00:00.000Z",
31
+ "completedAt": "2026-01-01T00:00:07.000Z",
32
+ "streamingUsage": {
33
+ "inputTokens": "2000",
34
+ "outputTokens": "90",
35
+ "totalTokens": "2090",
36
+ "turnCount": 1,
37
+ "estimatedCostUsd": 0.00236,
38
+ "model": "composer-2.5",
39
+ "observedAt": "2026-01-01T00:00:06.000Z",
40
+ "requestedServiceTier": "SERVICE_TIER_STANDARD",
41
+ "requestedModelParams": "[{\"id\":\"fast\",\"value\":\"false\"},{\"id\":\"thinking\",\"value\":\"false\"}]",
42
+ "requestedThinkingMode": "THINKING_MODE_DISABLED"
43
+ },
44
+ "fileReviewEventStream": {
45
+ "executionId": "aex_hermetic_0001",
46
+ "events": [
47
+ {
48
+ "eventId": "aex_hermetic_0001:0:aex_hermetic_0001:0:FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
49
+ "changeSetId": "aex_hermetic_0001:0",
50
+ "eventType": "FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
51
+ "timestamp": "2026-01-01T00:00:00.000Z",
52
+ "actor": "runner",
53
+ "baselineCaptured": {
54
+ "changeSetId": "aex_hermetic_0001:0",
55
+ "turnId": "aex_hermetic_0001:0",
56
+ "harnessId": "cursor",
57
+ "baselineSnapshot": {
58
+ "kind": "SNAPSHOT_KIND_CAS_MANIFEST"
59
+ }
60
+ }
61
+ }
62
+ ]
63
+ },
64
+ "fileChangeProgress": {
65
+ "changeSetId": "aex_hermetic_0001:0",
66
+ "capturedAt": "2026-01-01T00:00:01.000Z"
67
+ }
68
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "messages": [
3
+ {
4
+ "type": "MESSAGE_AI",
5
+ "content": "Starting on the parser.",
6
+ "timestamp": "2026-01-01T00:00:02.000Z"
7
+ },
8
+ {
9
+ "type": "MESSAGE_SYSTEM",
10
+ "content": "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
11
+ "timestamp": "2026-01-01T00:00:04.000Z"
12
+ },
13
+ {
14
+ "type": "MESSAGE_SYSTEM",
15
+ "content": "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
16
+ "timestamp": "2026-01-01T00:00:04.000Z"
17
+ }
18
+ ],
19
+ "phase": "EXECUTION_FAILED",
20
+ "error": "Execution interrupted: runner worker was shut down. Retry or resume.",
21
+ "startedAt": "2026-01-01T00:00:00.000Z",
22
+ "completedAt": "2026-01-01T00:00:04.000Z",
23
+ "fileReviewEventStream": {
24
+ "executionId": "aex_hermetic_0001",
25
+ "events": [
26
+ {
27
+ "eventId": "aex_hermetic_0001:0:aex_hermetic_0001:0:FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
28
+ "changeSetId": "aex_hermetic_0001:0",
29
+ "eventType": "FILE_REVIEW_EVENT_TYPE_BASELINE_CAPTURED",
30
+ "timestamp": "2026-01-01T00:00:00.000Z",
31
+ "actor": "runner",
32
+ "baselineCaptured": {
33
+ "changeSetId": "aex_hermetic_0001:0",
34
+ "turnId": "aex_hermetic_0001:0",
35
+ "harnessId": "cursor",
36
+ "baselineSnapshot": {
37
+ "kind": "SNAPSHOT_KIND_CAS_MANIFEST"
38
+ }
39
+ }
40
+ }
41
+ ]
42
+ },
43
+ "fileChangeProgress": {
44
+ "changeSetId": "aex_hermetic_0001:0",
45
+ "capturedAt": "2026-01-01T00:00:01.000Z"
46
+ }
47
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Hermetic goldens: USER PAUSE versus WORKER SHUTDOWN — the two interruptions
3
+ * that THROW, and the byte-pinned copy that tells them apart.
4
+ *
5
+ * Invariant pinned (the throw-vs-return table the runtime will own in S2; parent
6
+ * §5c): both interruptions end the activity with a thrown Temporal
7
+ * `CancelledFailure` (never a return), but they persist DIFFERENT terminal
8
+ * states the control plane keys on:
9
+ *
10
+ * - a user pause (Temporal delivers cancellation, no shutdown signal) persists
11
+ * EXECUTION_PAUSED with "Execution paused by user. Use resume to continue."
12
+ * and throws "Activity paused by orchestrator";
13
+ * - a worker shutdown (the runner's per-queue shutdown signal is aborted AND
14
+ * cancellation is delivered) persists EXECUTION_FAILED with the error copy
15
+ * "Execution interrupted: runner worker was shut down. Retry or resume." and
16
+ * throws "Activity cancelled (worker shutdown, not user pause)".
17
+ *
18
+ * The cloud channel decision table matches on that copy, so the goldens
19
+ * (`goldens/pause.status.json`, `goldens/worker-shutdown.status.json`) are the
20
+ * wire contract, character for character. Both interruptions are triggered from
21
+ * a script `effect` step — never a timer — so the instant is deterministic: the
22
+ * stream loop sees `isCancelled()` on its next iteration and never processes
23
+ * the event that follows.
24
+ *
25
+ * What the goldens ALSO pin, on purpose: both paths append their system message
26
+ * TWICE — once in `resolvePreBoundaryTerminal` before the throw, once again in
27
+ * the outer `catch (CancelledFailure)`, which re-stamps and re-persists. That is
28
+ * today's production behavior on the wire. S2 must reproduce it byte for byte;
29
+ * whether it is later collapsed to one message is a separate, deliberate change
30
+ * (recorded in the entry's execution log for the owner).
31
+ *
32
+ * Parent phase rows exercised: the stream loop's cancellation check; the
33
+ * post-stream `classifyTurnInterruption`; `resolvePreBoundaryTerminal`'s
34
+ * pause and worker-shutdown arms; the outer `CancelledFailure` catch; the
35
+ * `finally` teardown under a thrown exit.
36
+ *
37
+ * Regenerate ONLY after a deliberate behavior change:
38
+ * npx vitest run src/activities/execute-cursor/__tests__/hermetic -u
39
+ */
40
+
41
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
42
+ import { toJson } from "@bufbuild/protobuf";
43
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
44
+ import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
45
+
46
+ vi.mock("@cursor/sdk", async () =>
47
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
48
+ );
49
+ vi.mock("../../../../client/stigmer-client.js", async () =>
50
+ (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
51
+ );
52
+
53
+ import {
54
+ ScriptedClock,
55
+ createHermeticEnvironment,
56
+ threwCancelledFailure,
57
+ type HermeticEnvironment,
58
+ type InvocationControls,
59
+ } from "../../../../__test-utils__/hermetic-activity.js";
60
+ import { ScriptedCursorAgent, sdkEvents, step } from "../../__test-utils__/scripted-agent.js";
61
+ import {
62
+ SDK_CATALOG,
63
+ beginCursorScenario,
64
+ cursorExecutionRecord,
65
+ runCursorTurn,
66
+ stubRegistryFetch,
67
+ } from "../../__test-utils__/hermetic-cursor.js";
68
+
69
+ const USER_MESSAGE = "Refactor the parser module.";
70
+ const NEVER_SEEN = "This text must never reach the transcript.";
71
+
72
+ /**
73
+ * A turn that is interrupted mid-stream by `interrupt(controls)`: one assistant
74
+ * message lands, then the interruption, then an event the loop must NOT process.
75
+ */
76
+ function interruptedTurn(
77
+ agentId: string,
78
+ runId: string,
79
+ clock: ScriptedClock,
80
+ getControls: () => InvocationControls,
81
+ interrupt: (controls: InvocationControls) => void,
82
+ ): ScriptedCursorAgent {
83
+ const ev = sdkEvents(agentId, runId);
84
+ return new ScriptedCursorAgent({
85
+ agentId,
86
+ runIds: [runId],
87
+ observeStep: () => clock.tick(),
88
+ turns: [
89
+ [
90
+ step.event(ev.init()),
91
+ step.event(ev.assistant("Starting on the parser.")),
92
+ step.effect("interrupt the activity from outside", () => interrupt(getControls())),
93
+ step.event(ev.assistant(NEVER_SEEN)),
94
+ step.finished({ result: NEVER_SEEN }),
95
+ ],
96
+ ],
97
+ });
98
+ }
99
+
100
+ describe("ExecuteCursor hermetic — user pause vs worker shutdown", () => {
101
+ let env: HermeticEnvironment;
102
+ let registry: ReturnType<typeof stubRegistryFetch>;
103
+ const clock = new ScriptedClock();
104
+
105
+ beforeAll(() => {
106
+ env = createHermeticEnvironment();
107
+ registry = stubRegistryFetch();
108
+ clock.install();
109
+ });
110
+
111
+ afterAll(() => {
112
+ clock.uninstall();
113
+ registry.restore();
114
+ env.dispose();
115
+ });
116
+
117
+ it("a user pause persists PAUSED and throws CancelledFailure", async () => {
118
+ // ── Arrange ──────────────────────────────────────────────────────────────
119
+ clock.reset(); // both goldens read from :00
120
+ let controls: InvocationControls | undefined;
121
+ const agent = interruptedTurn(
122
+ "agent-hermetic-pause-0001",
123
+ "run-hermetic-pause-0001",
124
+ clock,
125
+ () => controls!,
126
+ (c) => c.cancel(),
127
+ );
128
+ const record = cursorExecutionRecord({ message: USER_MESSAGE });
129
+ const scenario = beginCursorScenario({ env, clock, record, sdk: { agents: [agent], catalog: SDK_CATALOG } });
130
+
131
+ // ── Act ──────────────────────────────────────────────────────────────────
132
+ const invocation = await runCursorTurn(scenario, { onControls: (c) => (controls = c) });
133
+
134
+ // ── Assert: the throw and the persisted state ────────────────────────────
135
+ expect(threwCancelledFailure(invocation.outcome), "a pause THROWS CancelledFailure").toBe(true);
136
+ if (!threwCancelledFailure(invocation.outcome)) throw new Error("unreachable");
137
+ expect(invocation.outcome.error.message).toBe("Activity paused by orchestrator");
138
+ expect(record.persistedPhases).toEqual([
139
+ ExecutionPhase.EXECUTION_IN_PROGRESS,
140
+ ExecutionPhase.EXECUTION_PAUSED,
141
+ ]);
142
+ const final = record.lastFullStatus!;
143
+ expect(final.error, "a pause is not an error").toBe("");
144
+ expect(final.completedAt, "a paused turn is not complete").toBe("");
145
+ expect(
146
+ final.messages.filter((m) => m.type === MessageType.MESSAGE_SYSTEM).map((m) => m.content),
147
+ ).toEqual([
148
+ "Execution paused by user. Use resume to continue.",
149
+ "Execution paused by user. Use resume to continue.",
150
+ ]);
151
+ expect(final.messages.some((m) => m.content === NEVER_SEEN), "nothing after the cancel is processed").toBe(false);
152
+
153
+ const json = JSON.stringify(toJson(AgentExecutionStatusSchema, final), null, 2) + "\n";
154
+ await expect(json).toMatchFileSnapshot("./goldens/pause.status.json");
155
+ });
156
+
157
+ it("a worker shutdown persists FAILED with the shutdown copy and throws CancelledFailure", async () => {
158
+ // ── Arrange ──────────────────────────────────────────────────────────────
159
+ clock.reset();
160
+ let controls: InvocationControls | undefined;
161
+ const agent = interruptedTurn(
162
+ "agent-hermetic-shutdown-0001",
163
+ "run-hermetic-shutdown-0001",
164
+ clock,
165
+ () => controls!,
166
+ (c) => {
167
+ // The runner-manager drains: the queue's shutdown signal aborts, then
168
+ // Temporal delivers the cancellation.
169
+ c.signalWorkerShutdown();
170
+ c.cancel();
171
+ },
172
+ );
173
+ const record = cursorExecutionRecord({ message: USER_MESSAGE });
174
+ const scenario = beginCursorScenario({ env, clock, record, sdk: { agents: [agent], catalog: SDK_CATALOG } });
175
+
176
+ // ── Act ──────────────────────────────────────────────────────────────────
177
+ const invocation = await runCursorTurn(scenario, { onControls: (c) => (controls = c) });
178
+
179
+ // ── Assert: the throw and the persisted state ────────────────────────────
180
+ expect(threwCancelledFailure(invocation.outcome), "a shutdown THROWS CancelledFailure").toBe(true);
181
+ if (!threwCancelledFailure(invocation.outcome)) throw new Error("unreachable");
182
+ expect(invocation.outcome.error.message).toBe("Activity cancelled (worker shutdown, not user pause)");
183
+ expect(record.persistedPhases).toEqual([
184
+ ExecutionPhase.EXECUTION_IN_PROGRESS,
185
+ ExecutionPhase.EXECUTION_FAILED,
186
+ ]);
187
+ const final = record.lastFullStatus!;
188
+ expect(final.error).toBe("Execution interrupted: runner worker was shut down. Retry or resume.");
189
+ expect(final.completedAt).not.toBe("");
190
+ expect(
191
+ final.messages.filter((m) => m.type === MessageType.MESSAGE_SYSTEM).map((m) => m.content),
192
+ ).toEqual([
193
+ "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
194
+ "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
195
+ ]);
196
+ expect(final.messages.some((m) => m.content === NEVER_SEEN)).toBe(false);
197
+
198
+ const json = JSON.stringify(toJson(AgentExecutionStatusSchema, final), null, 2) + "\n";
199
+ await expect(json).toMatchFileSnapshot("./goldens/worker-shutdown.status.json");
200
+ });
201
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Hermetic golden: a PLAIN TURN through the whole `ExecuteCursor` activity.
3
+ *
4
+ * Invariant pinned: an execution with no tools, no workspace entries, no MCP
5
+ * servers and no skills — the user asks, the agent answers — runs the full
6
+ * setup pipeline and ends COMPLETED with exactly the transcript, usage and
7
+ * timestamps recorded in `goldens/plain-turn.status.json`, RETURNING a slim
8
+ * status to the workflow (never throwing). This is the baseline every other
9
+ * scenario is a delta from, and the first proof that the activity is byte-
10
+ * stable under the hermetic driver.
11
+ *
12
+ * Parent phase rows exercised (20260908.02 `T01_0_plan.md` §3a): normalize
13
+ * input / heartbeat; artifact storage + the `persist` chokepoint; fetch
14
+ * execution, session, agent, blueprint; resolve environment (NOT_FOUND ->
15
+ * empty); provision workspace (per-session dir); workspace turn lock; MCP
16
+ * resolve (none); skills (none); attachments (none); HITL gate install;
17
+ * pricing + model id + variant params; create the engine session; write
18
+ * `harness_state_id` back; prompt composition; usage accumulation; run the
19
+ * turn; terminal mapping COMPLETED -> return; cleanup.
20
+ *
21
+ * Nothing here is a live Cursor agent: `@cursor/sdk` is the scripted double
22
+ * (`__test-utils__/scripted-sdk.ts`) and the control plane is the in-memory
23
+ * execution record (`__test-utils__/hermetic-activity.ts`). No network, no
24
+ * `CURSOR_API_KEY`, no Temporal worker.
25
+ *
26
+ * Regenerate the golden ONLY after a deliberate behavior change:
27
+ * npx vitest run src/activities/execute-cursor/__tests__/hermetic -u
28
+ */
29
+
30
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
31
+ import { toJson } from "@bufbuild/protobuf";
32
+ import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
33
+ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
34
+
35
+ // Both mocks are hoisted by vitest; they must live in this file. Each factory
36
+ // imports the reusable module and returns its mock surface.
37
+ vi.mock("@cursor/sdk", async () =>
38
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
39
+ );
40
+ vi.mock("../../../../client/stigmer-client.js", async () =>
41
+ (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
42
+ );
43
+
44
+ import {
45
+ ScriptedClock,
46
+ createHermeticEnvironment,
47
+ type HermeticEnvironment,
48
+ } from "../../../../__test-utils__/hermetic-activity.js";
49
+ import { ScriptedCursorAgent, step } from "../../__test-utils__/scripted-agent.js";
50
+ import {
51
+ FIXTURE,
52
+ SDK_CATALOG,
53
+ beginCursorScenario,
54
+ cursorExecutionRecord,
55
+ runCursorTurn,
56
+ stubRegistryFetch,
57
+ } from "../../__test-utils__/hermetic-cursor.js";
58
+
59
+ const AGENT_ID = "agent-hermetic-plain-0001";
60
+ const RUN_ID = "run-hermetic-plain-0001";
61
+ const USER_MESSAGE = "Say hello in five words or fewer.";
62
+ const ASSISTANT_TEXT = "Hello there, hermetic world.";
63
+
64
+ describe("ExecuteCursor hermetic — plain turn", () => {
65
+ let env: HermeticEnvironment;
66
+ let registry: ReturnType<typeof stubRegistryFetch>;
67
+ const clock = new ScriptedClock();
68
+
69
+ beforeAll(() => {
70
+ env = createHermeticEnvironment();
71
+ registry = stubRegistryFetch();
72
+ clock.install();
73
+ });
74
+
75
+ afterAll(() => {
76
+ clock.uninstall();
77
+ registry.restore();
78
+ env.dispose();
79
+ });
80
+
81
+ it("completes with the golden transcript and returns a slim COMPLETED status", async () => {
82
+ // ── Arrange ──────────────────────────────────────────────────────────────
83
+ const agent = new ScriptedCursorAgent({
84
+ agentId: AGENT_ID,
85
+ runIds: [RUN_ID],
86
+ observeStep: () => clock.tick(),
87
+ turns: [
88
+ [
89
+ step.event({ type: "system", subtype: "init", agent_id: AGENT_ID, run_id: RUN_ID }),
90
+ step.event({
91
+ type: "assistant",
92
+ agent_id: AGENT_ID,
93
+ run_id: RUN_ID,
94
+ message: { role: "assistant", content: [{ type: "text", text: ASSISTANT_TEXT }] },
95
+ }),
96
+ step.turnEnded({ inputTokens: 1_200, outputTokens: 40 }),
97
+ step.finished({
98
+ result: ASSISTANT_TEXT,
99
+ model: {
100
+ id: FIXTURE.model,
101
+ params: [
102
+ { id: "fast", value: "false" },
103
+ { id: "thinking", value: "false" },
104
+ ],
105
+ },
106
+ }),
107
+ ],
108
+ ],
109
+ });
110
+ const record = cursorExecutionRecord({ message: USER_MESSAGE });
111
+ const scenario = beginCursorScenario({
112
+ env,
113
+ clock,
114
+ record,
115
+ sdk: { agents: [agent], catalog: SDK_CATALOG },
116
+ });
117
+
118
+ // ── Act ──────────────────────────────────────────────────────────────────
119
+ const invocation = await runCursorTurn(scenario);
120
+
121
+ // ── Assert: the outcome the workflow sees ────────────────────────────────
122
+ expect(invocation.outcome.kind, "a completed turn RETURNS, never throws").toBe("returned");
123
+ const slim = (invocation.outcome as { value: Record<string, unknown> }).value;
124
+ expect(slim.phase).toBe("EXECUTION_COMPLETED");
125
+ expect(slim.final_text).toBe(ASSISTANT_TEXT);
126
+ expect(slim, "the slim return carries no transcript").not.toHaveProperty("messages");
127
+
128
+ // ── Assert: what the control plane received, in order ────────────────────
129
+ expect(record.persistedPhases).toEqual([
130
+ ExecutionPhase.EXECUTION_IN_PROGRESS,
131
+ ExecutionPhase.EXECUTION_COMPLETED,
132
+ ]);
133
+ expect(record.setupProgress, "the setup pipeline's phase labels, in order").toEqual([
134
+ "Fetching execution",
135
+ "Resolving agent blueprint",
136
+ "Resolving environment",
137
+ "Provisioning workspace",
138
+ "Resolving MCP servers",
139
+ "Resolving skills",
140
+ "Initializing Cursor agent",
141
+ ]);
142
+
143
+ // ── Assert: what the SDK was asked ───────────────────────────────────────
144
+ expect(scenario.sdk.resolutions).toHaveLength(1);
145
+ expect(scenario.sdk.resolutions[0].kind, "a fresh session creates, never resumes").toBe("create");
146
+ expect(scenario.sdk.resolutions[0].options?.model).toEqual({
147
+ id: FIXTURE.model,
148
+ params: [
149
+ { id: "fast", value: "false" },
150
+ { id: "thinking", value: "false" },
151
+ ],
152
+ });
153
+ expect(agent.sends).toHaveLength(1);
154
+ expect(String(agent.sends[0].message), "the prompt carries the user's message").toContain(USER_MESSAGE);
155
+ expect(agent.sends[0].hasOnDelta).toBe(true);
156
+
157
+ // ── Assert: the harness_state_id write-back ─────────────────────────────
158
+ expect(record.sessionUpdates).toHaveLength(1);
159
+ expect(record.sessionUpdates[0].spec?.harnessStateId).toBe(AGENT_ID);
160
+
161
+ // ── Assert: hermeticity ──────────────────────────────────────────────────
162
+ expect(invocation.heartbeats.length, "the whole-activity heartbeat ran").toBeGreaterThan(0);
163
+ expect(registry.urls.every((u) => u.includes("/model-registry")), "no network beyond the registry").toBe(true);
164
+
165
+ // ── Assert: the golden ───────────────────────────────────────────────────
166
+ const finalStatus = record.lastFullStatus;
167
+ expect(finalStatus).toBeDefined();
168
+ const json = JSON.stringify(toJson(AgentExecutionStatusSchema, finalStatus!), null, 2) + "\n";
169
+ await expect(json).toMatchFileSnapshot("./goldens/plain-turn.status.json");
170
+ });
171
+ });