@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/__test-utils__/hermetic-activity.d.ts +245 -0
- package/dist/__test-utils__/hermetic-activity.js +369 -0
- package/dist/__test-utils__/hermetic-activity.js.map +1 -0
- package/dist/__test-utils__/mock-client.d.ts +13 -0
- package/dist/__test-utils__/mock-client.js +45 -0
- package/dist/__test-utils__/mock-client.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +172 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +331 -0
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +167 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +239 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +97 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +132 -0
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -0
- package/dist/harness/capabilities.d.ts +71 -0
- package/dist/harness/capabilities.js +36 -0
- package/dist/harness/capabilities.js.map +1 -0
- package/dist/harness/registry.d.ts +67 -0
- package/dist/harness/registry.js +112 -0
- package/dist/harness/registry.js.map +1 -0
- package/dist/harness/types.d.ts +268 -0
- package/dist/harness/types.js +55 -0
- package/dist/harness/types.js.map +1 -0
- package/package.json +4 -4
- package/src/__test-utils__/__tests__/harness-contract-self-check.test.ts +229 -0
- package/src/__test-utils__/config-fixture.ts +63 -0
- package/src/__test-utils__/harness-contract/contract.ts +536 -0
- package/src/__test-utils__/harness-contract/recording-sink.ts +96 -0
- package/src/__test-utils__/harness-contract/scripted-adapter.ts +289 -0
- package/src/__test-utils__/harness-contract/types.ts +100 -0
- package/src/__test-utils__/hermetic-activity.ts +477 -0
- package/src/__test-utils__/proto-helpers.ts +25 -0
- package/src/__tests__/harness-contract.test.ts +25 -0
- package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +422 -0
- package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +342 -0
- package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +166 -0
- package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +228 -0
- package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +180 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn1.status.json +55 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn2.status.json +77 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +126 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/pause.status.json +45 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/plain-turn.status.json +48 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/recovery-fresh-agent.status.json +53 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-call.status.json +68 -0
- package/src/activities/execute-cursor/__tests__/hermetic/goldens/worker-shutdown.status.json +47 -0
- package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +201 -0
- package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +171 -0
- package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +156 -0
- package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +137 -0
- package/src/harness/__tests__/registry.test.ts +167 -0
- package/src/harness/capabilities.ts +75 -0
- package/src/harness/registry.ts +123 -0
- 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
|
+
}
|