@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,342 @@
1
+ /**
2
+ * Scripted `@cursor/sdk` agent — the double the hermetic `ExecuteCursor` runs
3
+ * drive instead of a live Cursor agent.
4
+ *
5
+ * The Cursor harness delegates the agent loop to the SDK: `agent.send()`
6
+ * returns a `Run` whose `stream()` yields `SDKMessage`s, whose `onDelta`
7
+ * callback carries usage, and whose `wait()` resolves the `RunResult`. Between
8
+ * those events the REAL SDK also performs side effects the harness observes
9
+ * only indirectly — it executes the tool (writing a file into the workspace) and
10
+ * it spawns the workspace's `.cursor/hooks.json` hook before a gated tool (the
11
+ * runner's own bash script, which appends to the denial ledger). A double that
12
+ * replays events alone would leave those effects out and the harness's
13
+ * deny-and-retry and file-review paths untested.
14
+ *
15
+ * So a turn is a SCRIPT of ordered steps, one of four kinds:
16
+ *
17
+ * - `event(SDKMessage)` — yielded from `stream()`
18
+ * - `delta(update)` — fired through `onDelta` (usage, `turn-ended`)
19
+ * - `effect(fn)` — the side effect the SDK would have performed
20
+ * (run the real hook, write a workspace file,
21
+ * cancel the activity from the outside)
22
+ * - `result(RunResult)` — what `wait()` resolves; ends the run
23
+ *
24
+ * The same shape as the native harness's `__test-utils__/scripted-model.ts`
25
+ * (`ScriptStep`, driven in order), applied to the SDK's event surface.
26
+ *
27
+ * Typing: `SDKMessage`, `Run`, `RunResult` and `SDKAgent` are the SDK's own
28
+ * types (`@cursor/sdk` 1.0.13, concrete in `messages.d.ts` / `run.d.ts` /
29
+ * `agent.d.ts`), so a script that drifts from the real event shape fails
30
+ * `tsc`. The delta channel is the exception: `InteractionUpdate` re-exports
31
+ * from `@anysphere/cursor-sdk-shared`, a workspace package the published SDK
32
+ * does not ship, so under `skipLibCheck` it resolves to `any` — in production
33
+ * code too (`turn-stream.ts`, `delta-enricher.ts` read it untyped). The one
34
+ * delta this double emits, the `turn-ended` usage, is therefore typed HERE
35
+ * ({@link TurnEndedUsageDelta}) against what `turn-stream.ts` reads from it,
36
+ * and that gap is recorded in the entry's findings rather than papered over.
37
+ *
38
+ * Cancellation: `run.cancel()` is how the harness stops a turn (first denial,
39
+ * cost cap, stall). The double honours it the way the SDK does — the stream
40
+ * ends at the next step, `wait()` resolves `{ status: "cancelled" }` — because
41
+ * the deny-and-retry path depends on exactly that ordering.
42
+ *
43
+ * Every `send()` consumes the NEXT script in the agent's queue: turn 1, then
44
+ * the resumed turn 2 on the same parked agent. A `send()` with no script left
45
+ * is a test bug and throws.
46
+ */
47
+
48
+ import type {
49
+ ModelSelection,
50
+ Run,
51
+ RunOperation,
52
+ RunResult,
53
+ RunStatus,
54
+ SDKAgent,
55
+ SDKMessage,
56
+ SDKUserMessage,
57
+ SendOptions,
58
+ } from "@cursor/sdk";
59
+ import type { ConversationTurn } from "@cursor/sdk";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Script steps
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * The `turn-ended` delta as `turn-stream.ts` reads it (`update.type ===
67
+ * "turn-ended" && update.usage` -> `usageAccumulator.addTurn(update.usage)`).
68
+ * See the module header for why this is typed locally.
69
+ */
70
+ export interface TurnEndedUsageDelta {
71
+ readonly type: "turn-ended";
72
+ readonly usage: {
73
+ readonly inputTokens?: number;
74
+ readonly outputTokens?: number;
75
+ readonly cacheWriteTokens?: number;
76
+ readonly cacheReadTokens?: number;
77
+ };
78
+ }
79
+
80
+ /** What an `effect` step can reach: the run it is part of. */
81
+ export interface EffectContext {
82
+ readonly run: ScriptedRun;
83
+ readonly agent: ScriptedCursorAgent;
84
+ }
85
+
86
+ export type ScriptStep =
87
+ | { readonly kind: "event"; readonly event: SDKMessage }
88
+ | { readonly kind: "delta"; readonly update: TurnEndedUsageDelta }
89
+ | { readonly kind: "effect"; readonly label: string; readonly run: (ctx: EffectContext) => void | Promise<void> }
90
+ | { readonly kind: "result"; readonly result: Omit<RunResult, "id"> };
91
+
92
+ /** One `send()`'s worth of behaviour. */
93
+ export type TurnScript = readonly ScriptStep[];
94
+
95
+ /** Step builders — the vocabulary a scenario file reads as. */
96
+ export const step = {
97
+ event(event: SDKMessage): ScriptStep {
98
+ return { kind: "event", event };
99
+ },
100
+ turnEnded(usage: TurnEndedUsageDelta["usage"]): ScriptStep {
101
+ return { kind: "delta", update: { type: "turn-ended", usage } };
102
+ },
103
+ effect(label: string, run: (ctx: EffectContext) => void | Promise<void>): ScriptStep {
104
+ return { kind: "effect", label, run };
105
+ },
106
+ finished(result?: Partial<Omit<RunResult, "id" | "status">>): ScriptStep {
107
+ return { kind: "result", result: { status: "finished", ...result } };
108
+ },
109
+ errored(result?: Partial<Omit<RunResult, "id" | "status">>): ScriptStep {
110
+ return { kind: "result", result: { status: "error", ...result } };
111
+ },
112
+ } as const;
113
+
114
+ /**
115
+ * `SDKMessage` builders bound to one (agent_id, run_id) pair, so a scenario
116
+ * reads as the conversation it scripts. Shapes are the SDK's `messages.d.ts`;
117
+ * tool names are the STREAM taxonomy (`edit`, `shell`, `read` — lowercase; the
118
+ * hook sees `Write`, `Shell`, `Read`; see `approval-gate.test.ts`).
119
+ */
120
+ export function sdkEvents(agentId: string, runId: string) {
121
+ const base = { agent_id: agentId, run_id: runId } as const;
122
+ return {
123
+ init(): SDKMessage {
124
+ return { type: "system", subtype: "init", ...base };
125
+ },
126
+ assistant(text: string): SDKMessage {
127
+ return { type: "assistant", ...base, message: { role: "assistant", content: [{ type: "text", text }] } };
128
+ },
129
+ thinking(text: string): SDKMessage {
130
+ return { type: "thinking", ...base, text };
131
+ },
132
+ toolCall(
133
+ callId: string,
134
+ name: string,
135
+ status: "running" | "completed" | "error",
136
+ args?: unknown,
137
+ result?: unknown,
138
+ ): SDKMessage {
139
+ return { type: "tool_call", ...base, call_id: callId, name, status, args, result };
140
+ },
141
+ status(status: "CREATING" | "RUNNING" | "FINISHED" | "ERROR" | "CANCELLED" | "EXPIRED", message?: string): SDKMessage {
142
+ return { type: "status", ...base, status, message };
143
+ },
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // The run
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /** Called once per step so the driver's clock can tick. */
152
+ export type StepObserver = (step: ScriptStep, index: number) => void;
153
+
154
+ export class ScriptedRun implements Run {
155
+ readonly id: string;
156
+ readonly agentId: string;
157
+ private _status: RunStatus = "running";
158
+ private cancelled = false;
159
+ private scriptedResult: Omit<RunResult, "id"> | undefined;
160
+ private streamed = false;
161
+ private readonly statusListeners = new Set<(status: RunStatus) => void>();
162
+ /** `run.cancel()` calls, for assertions. */
163
+ readonly cancelCalls: number[] = [];
164
+
165
+ constructor(
166
+ readonly agent: ScriptedCursorAgent,
167
+ id: string,
168
+ private readonly script: TurnScript,
169
+ private readonly onDelta: SendOptions["onDelta"],
170
+ private readonly observeStep: StepObserver | undefined,
171
+ /** What `conversation()` answers; the error classifier reads it on a failed run. */
172
+ private readonly conversationTurns: ConversationTurn[] = [],
173
+ ) {
174
+ this.id = id;
175
+ this.agentId = agent.agentId;
176
+ }
177
+
178
+ get status(): RunStatus {
179
+ return this._status;
180
+ }
181
+
182
+ get result(): string | undefined {
183
+ return this.scriptedResult?.result;
184
+ }
185
+
186
+ get model(): ModelSelection | undefined {
187
+ return this.scriptedResult?.model ?? this.agent.model;
188
+ }
189
+
190
+ supports(_operation: RunOperation): boolean {
191
+ return true;
192
+ }
193
+
194
+ unsupportedReason(_operation: RunOperation): string | undefined {
195
+ return undefined;
196
+ }
197
+
198
+ onDidChangeStatus(listener: (status: RunStatus) => void): () => void {
199
+ this.statusListeners.add(listener);
200
+ return () => this.statusListeners.delete(listener);
201
+ }
202
+
203
+ async conversation(): Promise<ConversationTurn[]> {
204
+ return this.conversationTurns;
205
+ }
206
+
207
+ async *stream(): AsyncGenerator<SDKMessage, void> {
208
+ if (this.streamed) {
209
+ throw new Error(`ScriptedRun ${this.id}: stream() consumed twice`);
210
+ }
211
+ this.streamed = true;
212
+ for (let i = 0; i < this.script.length; i++) {
213
+ // A cancel lands between steps, exactly where the SDK's own stream would
214
+ // stop delivering.
215
+ if (this.cancelled) break;
216
+ const s = this.script[i];
217
+ this.observeStep?.(s, i);
218
+ switch (s.kind) {
219
+ case "event":
220
+ yield s.event;
221
+ break;
222
+ case "delta":
223
+ await this.onDelta?.({ update: s.update as never });
224
+ break;
225
+ case "effect":
226
+ await s.run({ run: this, agent: this.agent });
227
+ break;
228
+ case "result":
229
+ this.scriptedResult = s.result;
230
+ break;
231
+ default: {
232
+ const exhaustive: never = s;
233
+ throw new Error(`ScriptedRun: unknown step ${String(exhaustive)}`);
234
+ }
235
+ }
236
+ }
237
+ if (!this.cancelled) this.setStatus(this.scriptedResult?.status ?? "finished");
238
+ }
239
+
240
+ async wait(): Promise<RunResult> {
241
+ if (this.cancelled) {
242
+ return { id: this.id, status: "cancelled" };
243
+ }
244
+ if (!this.scriptedResult) {
245
+ // A script that never declared its result is a test bug — surface it
246
+ // instead of inventing a "finished" the golden would then pin.
247
+ throw new Error(
248
+ `ScriptedRun ${this.id}: wait() called but the script has no result step ` +
249
+ `(add step.finished() or step.errored())`,
250
+ );
251
+ }
252
+ return { id: this.id, ...this.scriptedResult };
253
+ }
254
+
255
+ async cancel(): Promise<void> {
256
+ this.cancelCalls.push(Date.now());
257
+ this.cancelled = true;
258
+ this.setStatus("cancelled");
259
+ }
260
+
261
+ private setStatus(status: RunStatus): void {
262
+ this._status = status;
263
+ for (const l of this.statusListeners) l(status);
264
+ }
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // The agent
269
+ // ---------------------------------------------------------------------------
270
+
271
+ export interface ScriptedAgentOptions {
272
+ readonly agentId: string;
273
+ /** One script per `send()`, consumed in order. */
274
+ readonly turns: readonly TurnScript[];
275
+ /** Run ids, one per turn, so goldens carry stable `run_id`s. */
276
+ readonly runIds?: readonly string[];
277
+ readonly observeStep?: StepObserver;
278
+ readonly conversationTurns?: ConversationTurn[];
279
+ }
280
+
281
+ /** What the harness passed to `send()`, kept for assertions on the prompt. */
282
+ export interface RecordedSend {
283
+ readonly message: string | SDKUserMessage;
284
+ readonly hasOnDelta: boolean;
285
+ }
286
+
287
+ export class ScriptedCursorAgent implements SDKAgent {
288
+ readonly agentId: string;
289
+ model: ModelSelection | undefined = undefined;
290
+ readonly sends: RecordedSend[] = [];
291
+ readonly runs: ScriptedRun[] = [];
292
+ closeCalls = 0;
293
+ private nextTurn = 0;
294
+
295
+ constructor(private readonly options: ScriptedAgentOptions) {
296
+ this.agentId = options.agentId;
297
+ }
298
+
299
+ async send(message: string | SDKUserMessage, options?: SendOptions): Promise<Run> {
300
+ const script = this.options.turns[this.nextTurn];
301
+ if (!script) {
302
+ throw new Error(
303
+ `ScriptedCursorAgent ${this.agentId}: send() #${this.nextTurn + 1} has no script ` +
304
+ `(the scenario declared ${this.options.turns.length})`,
305
+ );
306
+ }
307
+ const runId = this.options.runIds?.[this.nextTurn] ?? `run-${this.agentId}-${this.nextTurn + 1}`;
308
+ this.nextTurn++;
309
+ this.sends.push({ message, hasOnDelta: !!options?.onDelta });
310
+ if (options?.model) this.model = options.model;
311
+ const run = new ScriptedRun(
312
+ this,
313
+ runId,
314
+ script,
315
+ options?.onDelta,
316
+ this.options.observeStep,
317
+ this.options.conversationTurns,
318
+ );
319
+ this.runs.push(run);
320
+ return run;
321
+ }
322
+
323
+ close(): void {
324
+ this.closeCalls++;
325
+ }
326
+
327
+ async reload(): Promise<void> {
328
+ // No persisted state to reload in the double.
329
+ }
330
+
331
+ async [Symbol.asyncDispose](): Promise<void> {
332
+ this.close();
333
+ }
334
+
335
+ async listArtifacts(): Promise<never[]> {
336
+ return [];
337
+ }
338
+
339
+ async downloadArtifact(_path: string): Promise<Buffer> {
340
+ throw new Error("ScriptedCursorAgent: artifacts are not part of the scripted surface");
341
+ }
342
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The `@cursor/sdk` module a hermetic `ExecuteCursor` run imports — every
3
+ * runtime surface the activity path touches, backed by scripted agents.
4
+ *
5
+ * The activity reaches the SDK at three runtime sites and nowhere else:
6
+ *
7
+ * - `session-lifecycle.ts`: `Agent.create(options)`, `Agent.resume(id, options)`,
8
+ * `Agent.archive(id, ...)` — the agent handle.
9
+ * - `service-tier.ts`: `Cursor.models.list({ apiKey })` — the catalog the
10
+ * variant params (`fast`, `thinking`) are pinned from.
11
+ * - `index.ts` outer catch: `import("@cursor/sdk")` for `CursorSdkError`, the
12
+ * `instanceof` the error classifier keys on.
13
+ *
14
+ * Neither the SDK nor the client is injectable into the activity (the real
15
+ * seam arrives with the harness contract; parent Q1/Q2), so — exactly as the
16
+ * native activity tests already do for their boundary modules — the module is
17
+ * substituted with `vi.mock`. Three existing tests each mock ONE of these
18
+ * surfaces (`session-lifecycle.test.ts`, `service-tier.test.ts`,
19
+ * `sdk-warmup.test.ts`); a whole-activity run needs all three at once, which is
20
+ * what this module is.
21
+ *
22
+ * `vi.mock` is hoisted and must appear in the test file; the factory can
23
+ * `await import()` this module and return {@link scriptedCursorSdkModule}. The
24
+ * module's statics read the {@link ScriptedCursorSdk} bound for the current
25
+ * scenario ({@link bindScriptedSdk}), so one test file can run several
26
+ * scenarios with different agents against the same mocked module.
27
+ *
28
+ * Resolution rules a scenario declares, mirroring what the real SDK does:
29
+ * - `Agent.create` hands out the NEXT unclaimed agent in `agents` (turn 1 of a
30
+ * fresh session; the fresh agent of a poisoned-handle recovery).
31
+ * - `Agent.resume(id)` hands back the agent with that id if the scenario
32
+ * declared it (in `agents` or `resumableAgents`), else throws — `resolveAgent`
33
+ * then falls back to `create`, which is the production shape of a lost handle.
34
+ * - `Cursor.models.list` answers the scenario's catalog.
35
+ */
36
+
37
+ import type { AgentOptions, ModelListItem, SDKAgent } from "@cursor/sdk";
38
+ import type { ScriptedCursorAgent } from "./scripted-agent.js";
39
+
40
+ export interface ScriptedSdkOptions {
41
+ /** Agents handed out by `Agent.create`, in order. Also resumable by id. */
42
+ readonly agents: readonly ScriptedCursorAgent[];
43
+ /**
44
+ * Agents `Agent.resume` finds by id but `Agent.create` never hands out — a
45
+ * previous turn's agent the session knows only by `harness_state_id`.
46
+ */
47
+ readonly resumableAgents?: readonly ScriptedCursorAgent[];
48
+ /** What `Cursor.models.list` answers. */
49
+ readonly catalog: readonly ModelListItem[];
50
+ }
51
+
52
+ /** One `Agent.create` / `Agent.resume` call as the activity made it. */
53
+ export interface RecordedResolution {
54
+ readonly kind: "create" | "resume";
55
+ readonly agentId: string | undefined;
56
+ readonly options: Partial<AgentOptions> | undefined;
57
+ }
58
+
59
+ export class ScriptedCursorSdk {
60
+ readonly resolutions: RecordedResolution[] = [];
61
+ readonly archived: string[] = [];
62
+ private nextCreate = 0;
63
+ private readonly byId = new Map<string, ScriptedCursorAgent>();
64
+
65
+ constructor(private readonly options: ScriptedSdkOptions) {
66
+ for (const a of [...options.agents, ...(options.resumableAgents ?? [])]) {
67
+ this.byId.set(a.agentId, a);
68
+ }
69
+ }
70
+
71
+ create(options: AgentOptions): SDKAgent {
72
+ const agent = this.options.agents[this.nextCreate];
73
+ if (!agent) {
74
+ throw new Error(
75
+ `ScriptedCursorSdk: Agent.create() #${this.nextCreate + 1} has no agent ` +
76
+ `(the scenario declared ${this.options.agents.length})`,
77
+ );
78
+ }
79
+ this.nextCreate++;
80
+ agent.model = options.model;
81
+ this.resolutions.push({ kind: "create", agentId: agent.agentId, options });
82
+ return agent;
83
+ }
84
+
85
+ resume(agentId: string, options: Partial<AgentOptions> | undefined): SDKAgent {
86
+ this.resolutions.push({ kind: "resume", agentId, options });
87
+ const agent = this.byId.get(agentId);
88
+ if (!agent) {
89
+ throw new ScriptedCursorSdkError(`agent ${agentId} not found`, { code: "agent_not_found" });
90
+ }
91
+ if (options?.model) agent.model = options.model;
92
+ return agent;
93
+ }
94
+
95
+ archive(agentId: string): void {
96
+ this.archived.push(agentId);
97
+ }
98
+
99
+ listModels(): readonly ModelListItem[] {
100
+ return this.options.catalog;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The `CursorSdkError` the mocked module exports. A real `Error` subclass with
106
+ * the two fields the classifier reads (`isRetryable`, `code`), under the SDK's
107
+ * class name so `err.constructor.name` and `instanceof` (against THIS module's
108
+ * export, which is what the activity's dynamic import resolves to) both hold.
109
+ */
110
+ export class ScriptedCursorSdkError extends Error {
111
+ readonly isRetryable: boolean;
112
+ readonly code: string | undefined;
113
+ constructor(message: string, opts: { isRetryable?: boolean; code?: string } = {}) {
114
+ super(message);
115
+ this.name = "CursorSdkError";
116
+ this.isRetryable = opts.isRetryable ?? false;
117
+ this.code = opts.code;
118
+ }
119
+ }
120
+
121
+ let bound: ScriptedCursorSdk | undefined;
122
+
123
+ /** Bind the SDK the mocked module serves for the current scenario. */
124
+ export function bindScriptedSdk(sdk: ScriptedCursorSdk): void {
125
+ bound = sdk;
126
+ }
127
+
128
+ function current(): ScriptedCursorSdk {
129
+ if (!bound) {
130
+ throw new Error(
131
+ "scripted-sdk: no ScriptedCursorSdk bound — call bindScriptedSdk(sdk) before running the activity",
132
+ );
133
+ }
134
+ return bound;
135
+ }
136
+
137
+ /**
138
+ * The factory a test passes to `vi.mock("@cursor/sdk", ...)`:
139
+ *
140
+ * ```ts
141
+ * vi.mock("@cursor/sdk", async () =>
142
+ * (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
143
+ * );
144
+ * ```
145
+ *
146
+ * Only the runtime surface the activity path uses is provided. Any other
147
+ * import from the mocked module is `undefined` and fails loudly at the call
148
+ * site — deliberately, so a new SDK dependency in production code is noticed
149
+ * here rather than silently stubbed.
150
+ */
151
+ export function scriptedCursorSdkModule(): Record<string, unknown> {
152
+ return {
153
+ Agent: {
154
+ create: async (options: AgentOptions): Promise<SDKAgent> => current().create(options),
155
+ resume: async (agentId: string, options?: Partial<AgentOptions>): Promise<SDKAgent> =>
156
+ current().resume(agentId, options),
157
+ archive: async (agentId: string): Promise<void> => current().archive(agentId),
158
+ },
159
+ Cursor: {
160
+ models: {
161
+ list: async (): Promise<readonly ModelListItem[]> => current().listModels(),
162
+ },
163
+ },
164
+ CursorSdkError: ScriptedCursorSdkError,
165
+ };
166
+ }