@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,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cursor slice of the hermetic activity driver: build the fixture record,
|
|
3
|
+
* the scripted SDK and the `Config`, run the REAL `ExecuteCursor` activity under
|
|
4
|
+
* `MockActivityEnvironment`, and hand back what it persisted.
|
|
5
|
+
*
|
|
6
|
+
* Layering (the `approval-contract/` + `gateway-substrate.ts` split): everything
|
|
7
|
+
* harness-agnostic lives in `src/__test-utils__/hermetic-activity.ts` (the
|
|
8
|
+
* activity environment, the execution record behind the client, the temp
|
|
9
|
+
* `~/.stigmer`, the ticking clock, the shutdown signal). This module adds only
|
|
10
|
+
* what is Cursor's: the `@cursor/sdk` double, the model catalog and pricing
|
|
11
|
+
* registry the harness reads at setup, the Cursor `Config` slice, and the
|
|
12
|
+
* per-scenario reset of the harness's module-level caches.
|
|
13
|
+
*
|
|
14
|
+
* What the two `vi.mock` calls a scenario file must carry look like — they are
|
|
15
|
+
* hoisted by vitest and so cannot live here:
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* vi.mock("@cursor/sdk", async () =>
|
|
19
|
+
* (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule());
|
|
20
|
+
* vi.mock("../../../../client/stigmer-client.js", async () =>
|
|
21
|
+
* (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule());
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Network: the activity's only HTTP fetch is the model registry
|
|
25
|
+
* (`model-pricing-data.ts`, `shared/model-registry.ts`; both fail SOFT to
|
|
26
|
+
* defaults with a 60s failure cache, which would make the goldens depend on
|
|
27
|
+
* DEFAULT_PRICING and log warnings on every run). `fetch` is stubbed to answer
|
|
28
|
+
* one registry document — and to THROW for any other URL, so a new network
|
|
29
|
+
* dependency on the activity path fails the hermetic run instead of leaking.
|
|
30
|
+
*
|
|
31
|
+
* The fixture model is `composer-2.5`, pinned (not Auto) on purpose: Auto short-
|
|
32
|
+
* circuits `resolveServiceTierParams` before the catalog, and a pinned model runs
|
|
33
|
+
* the catalog path, the variant-param pinning, and the `run.wait()` model echo
|
|
34
|
+
* check — more of the production path under the golden.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { execFileSync } from "node:child_process";
|
|
38
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { create } from "@bufbuild/protobuf";
|
|
41
|
+
import type { ModelListItem } from "@cursor/sdk";
|
|
42
|
+
import { vi } from "vitest";
|
|
43
|
+
import {
|
|
44
|
+
AgentExecutionSchema,
|
|
45
|
+
type AgentExecution,
|
|
46
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
47
|
+
import {
|
|
48
|
+
AgentExecutionSpecSchema,
|
|
49
|
+
ExecutionConfigSchema,
|
|
50
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
|
|
51
|
+
import { SessionSchema, type Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
52
|
+
import { SessionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/spec_pb";
|
|
53
|
+
import {
|
|
54
|
+
LocalPathSourceSchema,
|
|
55
|
+
WorkspaceEntrySchema,
|
|
56
|
+
WorkspaceSourceSchema,
|
|
57
|
+
type WorkspaceEntry,
|
|
58
|
+
} from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
|
|
59
|
+
import { AgentSchema, type Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
60
|
+
import { AgentSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
|
|
61
|
+
import {
|
|
62
|
+
AgentInstanceSchema,
|
|
63
|
+
type AgentInstance,
|
|
64
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
|
|
65
|
+
import { AgentInstanceSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/spec_pb";
|
|
66
|
+
import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
|
|
67
|
+
import type { Config } from "../../../config.js";
|
|
68
|
+
import type { ExecuteActivityInput } from "../../../shared/activity-input.js";
|
|
69
|
+
import {
|
|
70
|
+
ExecutionRecord,
|
|
71
|
+
ScriptedClock,
|
|
72
|
+
bindHermeticClient,
|
|
73
|
+
runActivityHermetically,
|
|
74
|
+
type ActivityInvocation,
|
|
75
|
+
type HermeticEnvironment,
|
|
76
|
+
type InvocationControls,
|
|
77
|
+
} from "../../../__test-utils__/hermetic-activity.js";
|
|
78
|
+
import { _resetAgentSessionCacheForTests } from "../agent-session-cache.js";
|
|
79
|
+
import { _resetPricingCache } from "../model-pricing-data.js";
|
|
80
|
+
import { resetCatalogCacheForTests } from "../service-tier.js";
|
|
81
|
+
import { _resetRegistryCache } from "../../../shared/model-registry.js";
|
|
82
|
+
import { ScriptedCursorSdk, bindScriptedSdk, type ScriptedSdkOptions } from "./scripted-sdk.js";
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Fixture ids — fixed so goldens are readable and byte-stable
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export const FIXTURE = {
|
|
89
|
+
org: "hermetic-org",
|
|
90
|
+
executionId: "aex_hermetic_0001",
|
|
91
|
+
sessionId: "ses_hermetic_0001",
|
|
92
|
+
agentInstanceId: "ain_hermetic_0001",
|
|
93
|
+
agentId: "agt_hermetic_0001",
|
|
94
|
+
agentName: "hermetic-agent",
|
|
95
|
+
/** The pinned model; in the registry stub AND the SDK catalog below. */
|
|
96
|
+
model: "composer-2.5",
|
|
97
|
+
cursorApiKey: "hermetic-cursor-api-key",
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// The record
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export interface CursorRecordOptions {
|
|
105
|
+
/** The user's message for this execution. */
|
|
106
|
+
readonly message: string;
|
|
107
|
+
/** The agent's instructions (system prompt body). */
|
|
108
|
+
readonly instructions?: string;
|
|
109
|
+
/** Session workspace entries (a `local_path` entry turns on capture mode). */
|
|
110
|
+
readonly workspaceEntries?: WorkspaceEntry[];
|
|
111
|
+
readonly modelName?: string;
|
|
112
|
+
readonly autoApproveAll?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The four resources of one execution, wired by id into a chain. */
|
|
116
|
+
export function cursorExecutionRecord(options: CursorRecordOptions): ExecutionRecord {
|
|
117
|
+
const execution: AgentExecution = create(AgentExecutionSchema, {
|
|
118
|
+
metadata: create(ApiResourceMetadataSchema, {
|
|
119
|
+
id: FIXTURE.executionId,
|
|
120
|
+
org: FIXTURE.org,
|
|
121
|
+
name: FIXTURE.executionId,
|
|
122
|
+
}),
|
|
123
|
+
spec: create(AgentExecutionSpecSchema, {
|
|
124
|
+
sessionId: FIXTURE.sessionId,
|
|
125
|
+
message: options.message,
|
|
126
|
+
autoApproveAll: options.autoApproveAll ?? false,
|
|
127
|
+
executionConfig: create(ExecutionConfigSchema, {
|
|
128
|
+
modelName: options.modelName ?? FIXTURE.model,
|
|
129
|
+
}),
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
const session: Session = create(SessionSchema, {
|
|
133
|
+
metadata: create(ApiResourceMetadataSchema, {
|
|
134
|
+
id: FIXTURE.sessionId,
|
|
135
|
+
org: FIXTURE.org,
|
|
136
|
+
name: FIXTURE.sessionId,
|
|
137
|
+
}),
|
|
138
|
+
spec: create(SessionSpecSchema, {
|
|
139
|
+
agentInstanceId: FIXTURE.agentInstanceId,
|
|
140
|
+
workspaceEntries: options.workspaceEntries ?? [],
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
const agentInstance: AgentInstance = create(AgentInstanceSchema, {
|
|
144
|
+
metadata: create(ApiResourceMetadataSchema, {
|
|
145
|
+
id: FIXTURE.agentInstanceId,
|
|
146
|
+
org: FIXTURE.org,
|
|
147
|
+
name: FIXTURE.agentInstanceId,
|
|
148
|
+
}),
|
|
149
|
+
spec: create(AgentInstanceSpecSchema, { agentId: FIXTURE.agentId }),
|
|
150
|
+
});
|
|
151
|
+
const agent: Agent = create(AgentSchema, {
|
|
152
|
+
metadata: create(ApiResourceMetadataSchema, {
|
|
153
|
+
id: FIXTURE.agentId,
|
|
154
|
+
org: FIXTURE.org,
|
|
155
|
+
name: FIXTURE.agentName,
|
|
156
|
+
}),
|
|
157
|
+
spec: create(AgentSpecSchema, {
|
|
158
|
+
description: "Hermetic fixture agent",
|
|
159
|
+
instructions: options.instructions ?? "You are the hermetic fixture agent. Answer briefly.",
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
return new ExecutionRecord({ execution, session, agentInstance, agent });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// The registry document and the SDK catalog
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The model registry document both readers parse (`parsePricingTable` wants
|
|
171
|
+
* `harness: "cursor"` + `pricing`; `parseRegistry` wants `id` + `provider` and
|
|
172
|
+
* reads `capabilities.vision`). One entry, the fixture model, priced at round
|
|
173
|
+
* numbers so the golden's `estimatedCostUsd` is legible.
|
|
174
|
+
*/
|
|
175
|
+
export const REGISTRY_DOCUMENT = {
|
|
176
|
+
models: [
|
|
177
|
+
{
|
|
178
|
+
id: FIXTURE.model,
|
|
179
|
+
displayName: "Composer 2.5 (hermetic fixture)",
|
|
180
|
+
provider: "cursor",
|
|
181
|
+
harness: "cursor",
|
|
182
|
+
costTier: "standard",
|
|
183
|
+
featured: true,
|
|
184
|
+
capabilities: { vision: true },
|
|
185
|
+
pricing: {
|
|
186
|
+
inputPricePerMillion: 1.0,
|
|
187
|
+
outputPricePerMillion: 4.0,
|
|
188
|
+
cacheWritePricePerMillion: 1.0,
|
|
189
|
+
cacheReadPricePerMillion: 0.1,
|
|
190
|
+
},
|
|
191
|
+
pricingVariants: {
|
|
192
|
+
fast: {
|
|
193
|
+
inputPricePerMillion: 3.0,
|
|
194
|
+
outputPricePerMillion: 12.0,
|
|
195
|
+
cacheWritePricePerMillion: 3.0,
|
|
196
|
+
cacheReadPricePerMillion: 0.3,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
} as const;
|
|
202
|
+
|
|
203
|
+
/** The SDK catalog entry for the fixture model: both pinnable params declared. */
|
|
204
|
+
export const SDK_CATALOG: readonly ModelListItem[] = [
|
|
205
|
+
{
|
|
206
|
+
id: FIXTURE.model,
|
|
207
|
+
displayName: "Composer 2.5",
|
|
208
|
+
parameters: [
|
|
209
|
+
{ id: "fast", values: [{ value: "true" }, { value: "false" }] },
|
|
210
|
+
{ id: "thinking", values: [{ value: "true" }, { value: "false" }] },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Stub `fetch` to answer the registry document and refuse everything else.
|
|
217
|
+
* Returns the URLs fetched, for the "no other network" assertion.
|
|
218
|
+
*/
|
|
219
|
+
export function stubRegistryFetch(): { readonly urls: string[]; restore(): void } {
|
|
220
|
+
const urls: string[] = [];
|
|
221
|
+
vi.stubGlobal(
|
|
222
|
+
"fetch",
|
|
223
|
+
vi.fn(async (input: string | URL | Request) => {
|
|
224
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
225
|
+
urls.push(url);
|
|
226
|
+
if (!url.includes("/model-registry")) {
|
|
227
|
+
throw new Error(`hermetic run attempted a non-registry network call: ${url}`);
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, status: 200, json: async () => REGISTRY_DOCUMENT } as unknown as Response;
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
return { urls, restore: () => vi.unstubAllGlobals() };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Config
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/** The OSS/local runner posture: direct Cursor API key, no proxy, local mode. */
|
|
240
|
+
export function hermeticCursorConfig(env: HermeticEnvironment): Config {
|
|
241
|
+
return {
|
|
242
|
+
taskQueue: "hermetic-test-queue",
|
|
243
|
+
temporalAddress: "localhost:7233",
|
|
244
|
+
temporalNamespace: "default",
|
|
245
|
+
stigmerBackendEndpoint: "http://localhost:7234",
|
|
246
|
+
mcpBridgeEndpoint: null,
|
|
247
|
+
stigmerToken: null,
|
|
248
|
+
cursorApiKey: FIXTURE.cursorApiKey,
|
|
249
|
+
workspaceRootDir: env.workspaceRootDir,
|
|
250
|
+
mode: "local",
|
|
251
|
+
proxyEndpoint: null,
|
|
252
|
+
maxConcurrentActivities: 1,
|
|
253
|
+
idleTimeoutSeconds: null,
|
|
254
|
+
cloudModeEnabled: false,
|
|
255
|
+
checkpointerType: "memory",
|
|
256
|
+
checkpointerProxyEndpoint: null,
|
|
257
|
+
artifactProxyEndpoint: null,
|
|
258
|
+
primaryModel: FIXTURE.model,
|
|
259
|
+
cursorStreamStallTimeoutMs: 180_000,
|
|
260
|
+
agentResolveTimeoutMs: 120_000,
|
|
261
|
+
workspaceLockTimeoutMs: 900_000,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// The side effects the real SDK performs, for `effect` steps
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The primary workspace directory a session with NO workspace entries runs in
|
|
271
|
+
* (`session-root.ts`: `<workspaceRootDir>/sessions/<sessionId>`).
|
|
272
|
+
*/
|
|
273
|
+
export function sessionWorkspaceDir(env: HermeticEnvironment): string {
|
|
274
|
+
return join(env.workspaceRootDir, "sessions", FIXTURE.sessionId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Run the workspace's installed preToolUse hook exactly as the Cursor SDK does:
|
|
279
|
+
* read `<workspaceRoot>/.cursor/hooks.json`, take the `preToolUse` command the
|
|
280
|
+
* gate installed (an absolute path to the runner's own stable bash script under
|
|
281
|
+
* `~/.stigmer/hitl-gate/<key>/`), and run it with the hook input on stdin. The
|
|
282
|
+
* script resolves the CURRENT turn's state file and denial ledger from the
|
|
283
|
+
* active-turn pointer, walks its parent PIDs to confirm this process is the
|
|
284
|
+
* runner, and prints `{"permission":"allow"|"deny"}`. A deny appends to the
|
|
285
|
+
* ledger the activity's stream loop reads.
|
|
286
|
+
*
|
|
287
|
+
* This is the out-of-process half of deny-and-retry, driven by the real script
|
|
288
|
+
* against the real gate the activity installed — not a simulation of either.
|
|
289
|
+
*/
|
|
290
|
+
export function runWorkspaceHook(
|
|
291
|
+
workspaceRoot: string,
|
|
292
|
+
hookInput: object,
|
|
293
|
+
): { readonly permission: "allow" | "deny" | "?"; readonly raw: string } {
|
|
294
|
+
const hooksJsonPath = join(workspaceRoot, ".cursor", "hooks.json");
|
|
295
|
+
const parsed = JSON.parse(readFileSync(hooksJsonPath, "utf-8")) as {
|
|
296
|
+
hooks?: { preToolUse?: Array<{ command: string }> };
|
|
297
|
+
};
|
|
298
|
+
const command = parsed.hooks?.preToolUse?.[0]?.command;
|
|
299
|
+
if (!command) {
|
|
300
|
+
throw new Error(`runWorkspaceHook: no preToolUse hook installed in ${hooksJsonPath}`);
|
|
301
|
+
}
|
|
302
|
+
const raw = execFileSync("bash", [command], { input: JSON.stringify(hookInput) }).toString();
|
|
303
|
+
const permission = raw.includes('"permission":"deny"')
|
|
304
|
+
? "deny"
|
|
305
|
+
: raw.includes('"permission":"allow"')
|
|
306
|
+
? "allow"
|
|
307
|
+
: "?";
|
|
308
|
+
return { permission, raw };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A git work tree with one committed file, for the file-review capture
|
|
313
|
+
* scenario. Author, committer and both dates are pinned so the commit AND tree
|
|
314
|
+
* object ids are byte-stable — a file-review golden may carry them.
|
|
315
|
+
*/
|
|
316
|
+
export function initGitWorkspace(root: string, files: Record<string, string>): void {
|
|
317
|
+
mkdirSync(root, { recursive: true });
|
|
318
|
+
const gitEnv = {
|
|
319
|
+
...process.env,
|
|
320
|
+
GIT_AUTHOR_NAME: "hermetic",
|
|
321
|
+
GIT_AUTHOR_EMAIL: "hermetic@stigmer.test",
|
|
322
|
+
GIT_COMMITTER_NAME: "hermetic",
|
|
323
|
+
GIT_COMMITTER_EMAIL: "hermetic@stigmer.test",
|
|
324
|
+
GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
|
|
325
|
+
GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
|
|
326
|
+
};
|
|
327
|
+
const git = (args: string[]): void => {
|
|
328
|
+
execFileSync("git", args, { cwd: root, env: gitEnv, stdio: "ignore" });
|
|
329
|
+
};
|
|
330
|
+
git(["init", "-q", "-b", "main"]);
|
|
331
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
332
|
+
const abs = join(root, rel);
|
|
333
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
334
|
+
writeFileSync(abs, content, "utf-8");
|
|
335
|
+
}
|
|
336
|
+
git(["add", "-A"]);
|
|
337
|
+
git(["commit", "-q", "-m", "initial"]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A session workspace entry mounting an absolute local path (local mode only). */
|
|
341
|
+
export function localPathEntry(name: string, path: string): WorkspaceEntry {
|
|
342
|
+
return create(WorkspaceEntrySchema, {
|
|
343
|
+
name,
|
|
344
|
+
source: create(WorkspaceSourceSchema, {
|
|
345
|
+
source: { case: "localPath", value: create(LocalPathSourceSchema, { path }) },
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Running a scenario
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Drop every module-level cache the harness keeps across invocations. Called
|
|
356
|
+
* at the START of a scenario, never between its invocations: a deny-and-retry
|
|
357
|
+
* scenario relies on the parked agent surviving from turn 1 into turn 2 exactly
|
|
358
|
+
* as it does in a live worker.
|
|
359
|
+
*/
|
|
360
|
+
export function resetCursorModuleState(): void {
|
|
361
|
+
_resetAgentSessionCacheForTests();
|
|
362
|
+
resetCatalogCacheForTests();
|
|
363
|
+
_resetPricingCache();
|
|
364
|
+
_resetRegistryCache();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface CursorScenario {
|
|
368
|
+
readonly env: HermeticEnvironment;
|
|
369
|
+
readonly clock: ScriptedClock;
|
|
370
|
+
readonly record: ExecutionRecord;
|
|
371
|
+
readonly sdk: ScriptedCursorSdk;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface CursorScenarioOptions {
|
|
375
|
+
readonly env: HermeticEnvironment;
|
|
376
|
+
readonly clock: ScriptedClock;
|
|
377
|
+
readonly record: ExecutionRecord;
|
|
378
|
+
readonly sdk: ScriptedSdkOptions;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Bind the record and the SDK for a scenario; resets the harness caches first. */
|
|
382
|
+
export function beginCursorScenario(options: CursorScenarioOptions): CursorScenario {
|
|
383
|
+
resetCursorModuleState();
|
|
384
|
+
const sdk = new ScriptedCursorSdk(options.sdk);
|
|
385
|
+
bindScriptedSdk(sdk);
|
|
386
|
+
bindHermeticClient(options.record.client());
|
|
387
|
+
return { env: options.env, clock: options.clock, record: options.record, sdk };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface CursorTurnOptions {
|
|
391
|
+
/** Empty for a first invocation; the agent id for a HITL reinvocation. */
|
|
392
|
+
readonly threadId?: string;
|
|
393
|
+
/** The HITL cycle index (0 on the first turn); mints the change-set id. */
|
|
394
|
+
readonly turnSeq?: number;
|
|
395
|
+
readonly onControls?: (controls: InvocationControls) => void;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Run ONE `ExecuteCursor` invocation for the scenario. The activities are
|
|
400
|
+
* constructed per invocation (the factory constructs its client, which is the
|
|
401
|
+
* one bound at `beginCursorScenario`).
|
|
402
|
+
*/
|
|
403
|
+
export async function runCursorTurn(
|
|
404
|
+
scenario: CursorScenario,
|
|
405
|
+
options: CursorTurnOptions = {},
|
|
406
|
+
): Promise<ActivityInvocation> {
|
|
407
|
+
// Imported lazily so the scenario file's `vi.mock` declarations are in
|
|
408
|
+
// force before the activity module (and, through it, `@cursor/sdk` and the
|
|
409
|
+
// client) is loaded.
|
|
410
|
+
const { createCursorActivities } = await import("../index.js");
|
|
411
|
+
const activities = createCursorActivities(hermeticCursorConfig(scenario.env));
|
|
412
|
+
// The typed wire shape the control plane's workflow sends (activity-input.ts).
|
|
413
|
+
const input: ExecuteActivityInput = {
|
|
414
|
+
execution_id: scenario.record.executionId,
|
|
415
|
+
thread_id: options.threadId ?? "",
|
|
416
|
+
turn_seq: options.turnSeq ?? 0,
|
|
417
|
+
};
|
|
418
|
+
return runActivityHermetically(activities.ExecuteCursor, [input], {
|
|
419
|
+
taskQueue: "hermetic-test-queue",
|
|
420
|
+
onControls: options.onControls,
|
|
421
|
+
});
|
|
422
|
+
}
|