@stigmer/runner 3.15.2 → 3.15.3-dev.20260914121148

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/activities/execute-cursor/__test-utils__/contract-subject.js +12 -9
  3. package/dist/activities/execute-cursor/__test-utils__/contract-subject.js.map +1 -1
  4. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +5 -2
  5. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +5 -2
  6. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
  7. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +11 -27
  8. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +13 -10
  9. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -1
  10. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +70 -26
  11. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +82 -32
  12. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -1
  13. package/dist/activities/execute-cursor/adapter.d.ts +4 -3
  14. package/dist/activities/execute-cursor/adapter.js +9 -5
  15. package/dist/activities/execute-cursor/adapter.js.map +1 -1
  16. package/dist/activities/execute-cursor/sdk-warmup.d.ts +15 -6
  17. package/dist/activities/execute-cursor/sdk-warmup.js +15 -6
  18. package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -1
  19. package/dist/activities/execute-cursor/session-lifecycle.d.ts +38 -59
  20. package/dist/activities/execute-cursor/session-lifecycle.js +59 -96
  21. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  22. package/dist/activities/execute-cursor/session-store.d.ts +100 -0
  23. package/dist/activities/execute-cursor/session-store.js +157 -0
  24. package/dist/activities/execute-cursor/session-store.js.map +1 -0
  25. package/dist/shared/attachment-vision.d.ts +2 -1
  26. package/dist/shared/attachment-vision.js.map +1 -1
  27. package/package.json +5 -5
  28. package/src/activities/execute-cursor/__test-utils__/__tests__/scripted-double.test.ts +31 -7
  29. package/src/activities/execute-cursor/__test-utils__/contract-subject.ts +10 -8
  30. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +5 -2
  31. package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +20 -28
  32. package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +94 -38
  33. package/src/activities/execute-cursor/__tests__/cursor-baseurl-routing.test.ts +10 -8
  34. package/src/activities/execute-cursor/__tests__/cursor-fetch-interceptor-bypass.test.ts +5 -4
  35. package/src/activities/execute-cursor/__tests__/cursor-generate-image-live.test.ts +7 -2
  36. package/src/activities/execute-cursor/__tests__/cursor-hook-protocol-live.test.ts +163 -0
  37. package/src/activities/execute-cursor/__tests__/cursor-sdk-auth-smoke.test.ts +13 -12
  38. package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +4 -1
  39. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +4 -1
  40. package/src/activities/execute-cursor/__tests__/hermetic/harness-contract.test.ts +3 -0
  41. package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +3 -0
  42. package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +4 -1
  43. package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +4 -1
  44. package/src/activities/execute-cursor/__tests__/hermetic/run-wait-arms.test.ts +4 -1
  45. package/src/activities/execute-cursor/__tests__/hermetic/stream-self-stop-arms.test.ts +4 -1
  46. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +3 -0
  47. package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +4 -1
  48. package/src/activities/execute-cursor/__tests__/hermetic/unattributed-hook-block.test.ts +4 -1
  49. package/src/activities/execute-cursor/__tests__/hermetic/workspace-lock-timeout.test.ts +3 -0
  50. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +86 -58
  51. package/src/activities/execute-cursor/__tests__/session-store.test.ts +220 -0
  52. package/src/activities/execute-cursor/adapter.ts +9 -5
  53. package/src/activities/execute-cursor/sdk-warmup.ts +15 -6
  54. package/src/activities/execute-cursor/session-lifecycle.ts +63 -109
  55. package/src/activities/execute-cursor/session-store.ts +187 -0
  56. package/src/shared/attachment-vision.ts +2 -1
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Live ground truth for the hook protocol the approval gate rides on — the
3
+ * standing instrument an `@cursor/sdk` bump runs before it is trusted
4
+ * (stigmer/stigmer#1053, the first bump; every later one inherits this).
5
+ *
6
+ * WHY THIS EXISTS. The Stigmer HITL gate on the Cursor harness is a
7
+ * `.cursor/hooks.json` `preToolUse` hook (`hook-script.ts`) that reads the
8
+ * SDK's stdin payload and answers `{"permission": "allow" | "deny"}`. Three
9
+ * facts about that payload are load-bearing and none is a TypeScript type:
10
+ *
11
+ * 1. `tool_name` is the HOOK taxonomy, PascalCase (`Write`, `Shell`, `Delete`;
12
+ * the stream taxonomy is lowercase `edit`, `shell`, `delete`) — the gated
13
+ * set `BUILT_IN_GATED` (`approval-policy.ts`) is spelled in it.
14
+ * 2. `tool_input` is an OBJECT for built-in tools (a JSON STRING for MCP
15
+ * tools under `beforeMCPExecution`) — the hook's identity token and the
16
+ * denial ledger's captured args are computed from it.
17
+ * 3. `hook_event_name` discriminates `preToolUse` from `beforeMCPExecution`.
18
+ *
19
+ * The hermetic tests prove the runner's handling of the shapes CAPTURED at
20
+ * 1.0.13 (`cursor-hook-harness.ts` `hookWrite`, `hookShell`, ...). They cannot
21
+ * see the SDK move. This test runs one real turn that edits a file and runs a
22
+ * shell command under an allow-everything observation hook, then asserts the
23
+ * three facts on what the real SDK sent. A `tool_name` outside the gated set
24
+ * for a write-, shell- or delete-class action is the one outcome that must
25
+ * stop a bump: the gate would let it through.
26
+ *
27
+ * Skipped without CURSOR_API_KEY (the cursor-sdk-auth-smoke.test.ts
28
+ * convention) — it spends real credits for one short turn. Findings are
29
+ * PRINTED as well as asserted, so a bump's PR can quote the shapes seen.
30
+ *
31
+ * Run with: CURSOR_API_KEY=<key> npx vitest run cursor-hook-protocol-live
32
+ */
33
+ import { describe, it, expect } from "vitest";
34
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
35
+ import { tmpdir } from "node:os";
36
+ import { join } from "node:path";
37
+ import { approvalCategory } from "../approval-policy.js";
38
+
39
+ const CURSOR_API_KEY = process.env.CURSOR_API_KEY ?? "";
40
+ const describeWithCursorKey = CURSOR_API_KEY ? describe : describe.skip;
41
+
42
+ /** One hook invocation as the SDK wrote it to the hook's stdin. */
43
+ interface HookInvocation {
44
+ readonly hook_event_name?: unknown;
45
+ readonly tool_name?: unknown;
46
+ readonly tool_input?: unknown;
47
+ }
48
+
49
+ /**
50
+ * An observation-only preToolUse/beforeMCPExecution hook: appends every
51
+ * invocation's stdin JSON to a log file and ALLOWS everything, so the turn
52
+ * runs to completion and every gated action is seen exactly as the gate would
53
+ * see it.
54
+ */
55
+ function installObservationHook(workspaceRoot: string, logPath: string): void {
56
+ const hooksDir = join(workspaceRoot, ".cursor");
57
+ mkdirSync(hooksDir, { recursive: true });
58
+ const scriptPath = join(hooksDir, "observe-hook.sh");
59
+ writeFileSync(
60
+ scriptPath,
61
+ `#!/bin/bash\nINPUT=$(cat)\nprintf '%s\\n' "$INPUT" >> "${logPath}" 2>/dev/null || true\necho '{"permission":"allow"}'\n`,
62
+ "utf-8",
63
+ );
64
+ chmodSync(scriptPath, 0o755);
65
+ writeFileSync(
66
+ join(hooksDir, "hooks.json"),
67
+ JSON.stringify({
68
+ version: 1,
69
+ hooks: {
70
+ preToolUse: [{ command: scriptPath }],
71
+ beforeMCPExecution: [{ command: scriptPath }],
72
+ },
73
+ }),
74
+ "utf-8",
75
+ );
76
+ }
77
+
78
+ function readInvocations(logPath: string): HookInvocation[] {
79
+ if (!existsSync(logPath)) return [];
80
+ const invocations: HookInvocation[] = [];
81
+ for (const line of readFileSync(logPath, "utf-8").split("\n")) {
82
+ if (!line.trim()) continue;
83
+ try {
84
+ invocations.push(JSON.parse(line) as HookInvocation);
85
+ } catch {
86
+ /* a partial line from a hook the SDK cut off; nothing to read */
87
+ }
88
+ }
89
+ return invocations;
90
+ }
91
+
92
+ describeWithCursorKey("Cursor SDK hook protocol (live ground truth)", () => {
93
+ it("delivers PascalCase tool_name, an object tool_input and hook_event_name for a file edit and a shell command", async () => {
94
+ const { Agent } = await import("@cursor/sdk");
95
+ const { SqliteLocalAgentStore } = await import("@cursor/sdk/sqlite");
96
+
97
+ const workspaceRoot = mkdtempSync(join(tmpdir(), "stigmer-hook-protocol-"));
98
+ const stateRoot = join(workspaceRoot, ".sdk-state");
99
+ mkdirSync(stateRoot, { recursive: true });
100
+ const hookLog = join(workspaceRoot, "hook-invocations.jsonl");
101
+ installObservationHook(workspaceRoot, hookLog);
102
+
103
+ // Mirror the runner's arrangement (session-lifecycle.ts): local cwd, the
104
+ // "project" setting source that loads .cursor/hooks.json, the session's
105
+ // own store, the SDK's retries off.
106
+ const agent = await Agent.create({
107
+ apiKey: CURSOR_API_KEY,
108
+ model: { id: "composer-2.5" },
109
+ local: {
110
+ cwd: workspaceRoot,
111
+ settingSources: ["project"],
112
+ store: await SqliteLocalAgentStore.open({ workspaceRef: `hook-protocol-${Date.now()}`, stateRoot }),
113
+ enableAgentRetries: false,
114
+ },
115
+ });
116
+
117
+ const run = await agent.send(
118
+ "Do exactly two things, in order, without asking questions: " +
119
+ "(1) create a file named probe.txt in the workspace containing the single line 'hook probe'; " +
120
+ "(2) run the shell command `cat probe.txt`. Then reply with the word done.",
121
+ );
122
+ for await (const _event of run.stream()) {
123
+ /* drain: the hook log is the evidence, not the stream */
124
+ }
125
+ const result = await run.wait();
126
+ agent.close();
127
+
128
+ const invocations = readInvocations(hookLog);
129
+
130
+ // ---- Findings dump (what a bump's PR quotes) ----
131
+ console.log(`[hook-protocol] run status: ${result.status}`);
132
+ for (const inv of invocations) {
133
+ console.log(
134
+ `[hook-protocol] ${String(inv.hook_event_name)}:${String(inv.tool_name)} ` +
135
+ `tool_input=${typeof inv.tool_input} ${JSON.stringify(inv.tool_input).slice(0, 200)}`,
136
+ );
137
+ }
138
+
139
+ // The SDK boundary must not hang, and the turn must have reached the hook.
140
+ expect(["finished", "error", "cancelled"]).toContain(result.status);
141
+ expect(invocations.length, "the observation hook saw no tool call — hooks.json was not loaded").toBeGreaterThan(0);
142
+
143
+ const builtIns = invocations.filter((inv) => inv.hook_event_name === "preToolUse");
144
+ expect(builtIns.length, "no preToolUse invocation: the built-in tools did not reach the hook").toBeGreaterThan(0);
145
+ for (const inv of builtIns) {
146
+ // Fact 3: the discriminator is present and spelled as the script branches on it.
147
+ expect(inv.hook_event_name).toBe("preToolUse");
148
+ // Fact 1: the hook taxonomy is PascalCase.
149
+ expect(typeof inv.tool_name).toBe("string");
150
+ expect(inv.tool_name as string).toMatch(/^[A-Z]/);
151
+ // Fact 2: built-in tool_input is an object, not a JSON string.
152
+ expect(typeof inv.tool_input).toBe("object");
153
+ expect(inv.tool_input).not.toBeNull();
154
+ }
155
+
156
+ // The gate's reach: the write and the shell the prompt forced must have
157
+ // arrived under names the gate classifies. A write- or shell-class action
158
+ // under an unclassified name is the outcome that stops a bump.
159
+ const categories = builtIns.map((inv) => approvalCategory(inv.tool_name as string));
160
+ expect(categories, "the file write reached the hook under a name the gate classifies as write").toContain("write");
161
+ expect(categories, "the shell command reached the hook under a name the gate classifies as shell").toContain("shell");
162
+ }, 300_000);
163
+ });
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { describe, it, expect } from "vitest";
14
14
  import { Agent } from "@cursor/sdk";
15
+ import { SqliteLocalAgentStore } from "@cursor/sdk/sqlite";
15
16
  import { mkdirSync } from "node:fs";
16
17
  import { join } from "node:path";
17
18
  import { tmpdir } from "node:os";
@@ -31,10 +32,10 @@ describeWithCursorKey("Cursor SDK Authentication Smoke Test", () => {
31
32
  const agent = await Agent.create({
32
33
  apiKey: CURSOR_API_KEY,
33
34
  model: { id: "claude-sonnet-4" },
34
- local: { cwd: stateRoot },
35
- platform: {
36
- workspaceRef: `auth-test-${Date.now()}`,
37
- stateRoot,
35
+ local: {
36
+ cwd: stateRoot,
37
+ // 1.0.31: the store is caller-owned (`session-store.ts` in production).
38
+ store: await SqliteLocalAgentStore.open({ workspaceRef: `auth-test-${Date.now()}`, stateRoot }),
38
39
  },
39
40
  });
40
41
 
@@ -50,10 +51,10 @@ describeWithCursorKey("Cursor SDK Authentication Smoke Test", () => {
50
51
  const agent = await Agent.create({
51
52
  apiKey: CURSOR_API_KEY,
52
53
  model: { id: "claude-sonnet-4" },
53
- local: { cwd: stateRoot },
54
- platform: {
55
- workspaceRef: `send-test-${Date.now()}`,
56
- stateRoot,
54
+ local: {
55
+ cwd: stateRoot,
56
+ // 1.0.31: the store is caller-owned (`session-store.ts` in production).
57
+ store: await SqliteLocalAgentStore.open({ workspaceRef: `send-test-${Date.now()}`, stateRoot }),
57
58
  },
58
59
  });
59
60
 
@@ -76,10 +77,10 @@ describeWithCursorKey("Cursor SDK Authentication Smoke Test", () => {
76
77
 
77
78
  const agent = await Agent.create({
78
79
  apiKey: CURSOR_API_KEY,
79
- local: { cwd: stateRoot },
80
- platform: {
81
- workspaceRef: `default-model-test-${Date.now()}`,
82
- stateRoot,
80
+ local: {
81
+ cwd: stateRoot,
82
+ // 1.0.31: the store is caller-owned (`session-store.ts` in production).
83
+ store: await SqliteLocalAgentStore.open({ workspaceRef: `default-model-test-${Date.now()}`, stateRoot }),
83
84
  },
84
85
  });
85
86
 
@@ -50,6 +50,9 @@ import {
50
50
  vi.mock("@cursor/sdk", async () =>
51
51
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
52
52
  );
53
+ vi.mock("@cursor/sdk/sqlite", async () =>
54
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
55
+ );
53
56
  vi.mock("../../../../client/stigmer-client.js", async () =>
54
57
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
55
58
  );
@@ -137,7 +140,7 @@ describe.skipIf(!hasBash)("ExecuteCursor hermetic — deny-and-retry approval ro
137
140
  step.event(ev2.toolCall(CALL_TURN_2, "shell", "running", SHELL_ARGS)),
138
141
  step.event(ev2.toolCall(CALL_TURN_2, "shell", "completed", SHELL_ARGS, BUILD_OUTPUT)),
139
142
  step.event(ev2.assistant(FINAL_TEXT)),
140
- step.turnEnded({ inputTokens: 3_100, outputTokens: 60 }),
143
+ step.turnEnded({ inputTokens: 3_100, outputTokens: 60, cacheReadTokens: 0, cacheWriteTokens: 0 }),
141
144
  step.finished({ result: FINAL_TEXT, model: { id: FIXTURE.model, params: [] } }),
142
145
  ],
143
146
  ],
@@ -42,6 +42,9 @@ import { ExecutionPhase, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agent
42
42
  vi.mock("@cursor/sdk", async () =>
43
43
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
44
44
  );
45
+ vi.mock("@cursor/sdk/sqlite", async () =>
46
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
47
+ );
45
48
  vi.mock("../../../../client/stigmer-client.js", async () =>
46
49
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
47
50
  );
@@ -116,7 +119,7 @@ describe.skipIf(!hasBash)("ExecuteCursor hermetic — file-review capture on a g
116
119
  step.event(ev.toolCall(CALL_ID, "edit", "running", { path: FILE, content: AFTER })),
117
120
  step.event(ev.toolCall(CALL_ID, "edit", "completed", { path: FILE, content: AFTER }, "ok")),
118
121
  step.event(ev.assistant("Done — notes.md now records the review.")),
119
- step.turnEnded({ inputTokens: 2_400, outputTokens: 70 }),
122
+ step.turnEnded({ inputTokens: 2_400, outputTokens: 70, cacheReadTokens: 0, cacheWriteTokens: 0 }),
120
123
  step.finished({ result: "Done", model: { id: FIXTURE.model, params: [] } }),
121
124
  ],
122
125
  ],
@@ -55,6 +55,9 @@ import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agent
55
55
  vi.mock("@cursor/sdk", async () =>
56
56
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
57
57
  );
58
+ vi.mock("@cursor/sdk/sqlite", async () =>
59
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
60
+ );
58
61
  vi.mock("../../../../client/stigmer-client.js", async () =>
59
62
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
60
63
  );
@@ -54,6 +54,9 @@ import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/
54
54
  vi.mock("@cursor/sdk", async () =>
55
55
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
56
56
  );
57
+ vi.mock("@cursor/sdk/sqlite", async () =>
58
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
59
+ );
57
60
  vi.mock("../../../../client/stigmer-client.js", async () =>
58
61
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
59
62
  );
@@ -37,6 +37,9 @@ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecutio
37
37
  vi.mock("@cursor/sdk", async () =>
38
38
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
39
39
  );
40
+ vi.mock("@cursor/sdk/sqlite", async () =>
41
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
42
+ );
40
43
  vi.mock("../../../../client/stigmer-client.js", async () =>
41
44
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
42
45
  );
@@ -93,7 +96,7 @@ describe("ExecuteCursor hermetic — plain turn", () => {
93
96
  run_id: RUN_ID,
94
97
  message: { role: "assistant", content: [{ type: "text", text: ASSISTANT_TEXT }] },
95
98
  }),
96
- step.turnEnded({ inputTokens: 1_200, outputTokens: 40 }),
99
+ step.turnEnded({ inputTokens: 1_200, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 0 }),
97
100
  step.finished({
98
101
  result: ASSISTANT_TEXT,
99
102
  model: {
@@ -33,6 +33,9 @@ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecutio
33
33
  vi.mock("@cursor/sdk", async () =>
34
34
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
35
35
  );
36
+ vi.mock("@cursor/sdk/sqlite", async () =>
37
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
38
+ );
36
39
  vi.mock("../../../../client/stigmer-client.js", async () =>
37
40
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
38
41
  );
@@ -104,7 +107,7 @@ describe("ExecuteCursor hermetic — poisoned-handle recovery on a fresh agent",
104
107
  [
105
108
  step.event(evFresh.init()),
106
109
  step.event(evFresh.assistant(FINAL_TEXT)),
107
- step.turnEnded({ inputTokens: 2_800, outputTokens: 55 }),
110
+ step.turnEnded({ inputTokens: 2_800, outputTokens: 55, cacheReadTokens: 0, cacheWriteTokens: 0 }),
108
111
  step.finished({ result: FINAL_TEXT, model: { id: FIXTURE.model, params: [] } }),
109
112
  ],
110
113
  ],
@@ -44,6 +44,9 @@ import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/
44
44
  vi.mock("@cursor/sdk", async () =>
45
45
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
46
46
  );
47
+ vi.mock("@cursor/sdk/sqlite", async () =>
48
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
49
+ );
47
50
  vi.mock("../../../../client/stigmer-client.js", async () =>
48
51
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
49
52
  );
@@ -175,7 +178,7 @@ describe("ExecuteCursor hermetic — the run.wait() arms on a created agent", ()
175
178
  [
176
179
  step.event(evFresh.init()),
177
180
  step.event(evFresh.assistant(FINAL_TEXT)),
178
- step.turnEnded({ inputTokens: 2_200, outputTokens: 30 }),
181
+ step.turnEnded({ inputTokens: 2_200, outputTokens: 30, cacheReadTokens: 0, cacheWriteTokens: 0 }),
179
182
  step.finished({ result: FINAL_TEXT, model: { id: FIXTURE.model, params: [] } }),
180
183
  ],
181
184
  ],
@@ -64,6 +64,9 @@ import {
64
64
  vi.mock("@cursor/sdk", async () =>
65
65
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
66
66
  );
67
+ vi.mock("@cursor/sdk/sqlite", async () =>
68
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
69
+ );
67
70
  vi.mock("../../../../client/stigmer-client.js", async () =>
68
71
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
69
72
  );
@@ -213,7 +216,7 @@ describe("ExecuteCursor hermetic — the stream loop's self-stop arms", () => {
213
216
  step.event(ev.init()),
214
217
  step.event(ev.assistant("Reading the whole repository first.")),
215
218
  // 600 000 input tokens at the fixture's $1/M base rate = $0.60 > $0.50.
216
- step.turnEnded({ inputTokens: 600_000, outputTokens: 0 }),
219
+ step.turnEnded({ inputTokens: 600_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }),
217
220
  step.event(ev.assistant(NEVER_SEEN)),
218
221
  step.finished({ result: NEVER_SEEN }),
219
222
  ],
@@ -49,6 +49,9 @@ import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/
49
49
  vi.mock("@cursor/sdk", async () =>
50
50
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
51
51
  );
52
+ vi.mock("@cursor/sdk/sqlite", async () =>
53
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
54
+ );
52
55
  vi.mock("../../../../client/stigmer-client.js", async () =>
53
56
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
54
57
  );
@@ -28,6 +28,9 @@ import { ExecutionPhase, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agent
28
28
  vi.mock("@cursor/sdk", async () =>
29
29
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
30
30
  );
31
+ vi.mock("@cursor/sdk/sqlite", async () =>
32
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
33
+ );
31
34
  vi.mock("../../../../client/stigmer-client.js", async () =>
32
35
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
33
36
  );
@@ -86,7 +89,7 @@ describe("ExecuteCursor hermetic — ungated tool call", () => {
86
89
  step.event(ev.toolCall(CALL_ID, "read", "running", READ_ARGS)),
87
90
  step.event(ev.toolCall(CALL_ID, "read", "completed", READ_ARGS, READ_RESULT)),
88
91
  step.event(ev.assistant(ASSISTANT_TEXT)),
89
- step.turnEnded({ inputTokens: 2_000, outputTokens: 90 }),
92
+ step.turnEnded({ inputTokens: 2_000, outputTokens: 90, cacheReadTokens: 0, cacheWriteTokens: 0 }),
90
93
  step.finished({ result: ASSISTANT_TEXT, model: { id: FIXTURE.model, params: [] } }),
91
94
  ],
92
95
  ],
@@ -45,6 +45,9 @@ import {
45
45
  vi.mock("@cursor/sdk", async () =>
46
46
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
47
47
  );
48
+ vi.mock("@cursor/sdk/sqlite", async () =>
49
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
50
+ );
48
51
  vi.mock("../../../../client/stigmer-client.js", async () =>
49
52
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
50
53
  );
@@ -123,7 +126,7 @@ describe("ExecuteCursor hermetic — unattributed hook block (#205)", () => {
123
126
  // failed with its generic hook-block text. Our ledger never saw it.
124
127
  step.event(ev.toolCall(CALL_ID, "shell", "error", SHELL_ARGS, HOOK_BLOCK_ERROR)),
125
128
  step.event(ev.assistant(FINAL_TEXT)),
126
- step.turnEnded({ inputTokens: 1_500, outputTokens: 40 }),
129
+ step.turnEnded({ inputTokens: 1_500, outputTokens: 40, cacheReadTokens: 0, cacheWriteTokens: 0 }),
127
130
  step.finished({ result: FINAL_TEXT }),
128
131
  ],
129
132
  ],
@@ -35,6 +35,9 @@ import { ExecutionPhase, MessageType } from "@stigmer/protos/ai/stigmer/agentic/
35
35
  vi.mock("@cursor/sdk", async () =>
36
36
  (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSdkModule(),
37
37
  );
38
+ vi.mock("@cursor/sdk/sqlite", async () =>
39
+ (await import("../../__test-utils__/scripted-sdk.js")).scriptedCursorSqliteModule(),
40
+ );
38
41
  vi.mock("../../../../client/stigmer-client.js", async () =>
39
42
  (await import("../../../../__test-utils__/hermetic-activity.js")).hermeticStigmerClientModule(),
40
43
  );
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Unit tests for resolvePlatformOptions the Cursor SDK stateRoot derivation.
2
+ * Unit tests for the local agent's create/resume options and the
3
+ * transport-recovery wrapper (`session-lifecycle.ts`).
3
4
  *
4
- * stateRoot is keyed by sessionId and rooted at the durable workspace volume so
5
- * native Agent.resume() survives restart/snapshot-restore and so sessions that
6
- * share one volume (e.g. a workflow sandbox's child agent executions) never
7
- * collide. These invariants are correctness-critical, hence the explicit tests.
5
+ * The SDK module is a `vi.fn` stub so the assertions read the exact options
6
+ * the adapter hands `Agent.create` / `Agent.resume`. The SDK's sqlite entry is
7
+ * NOT mocked here on purpose: `createAgent` opens the session's REAL
8
+ * `SqliteLocalAgentStore` (`node:sqlite`, no network) under a temp workspace
9
+ * volume, so this file is also where the store the runner ships with is
10
+ * proven to open, be reused, and dispose — the hermetic goldens double it.
11
+ * The store's keying and lifetime rules have their own arms in
12
+ * `session-store.test.ts`.
8
13
  */
9
14
 
10
15
  import { describe, it, expect, afterEach, vi } from "vitest";
@@ -20,13 +25,15 @@ vi.mock("@cursor/sdk", () => ({
20
25
  }));
21
26
 
22
27
  import { Agent } from "@cursor/sdk";
28
+ import type { AgentOptions } from "@cursor/sdk";
29
+ import { SqliteLocalAgentStore } from "@cursor/sdk/sqlite";
23
30
  import {
24
- resolvePlatformOptions,
25
31
  createAgent,
26
32
  resumeAgent,
27
33
  resolveAgentWithTransportRecovery,
28
34
  } from "../session-lifecycle.js";
29
35
  import type { CreateAgentOptions } from "../session-lifecycle.js";
36
+ import { releaseAllSessionStores } from "../session-store.js";
30
37
 
31
38
  const tempRoots: string[] = [];
32
39
 
@@ -36,50 +43,25 @@ function freshWorkspaceRoot(): string {
36
43
  return dir;
37
44
  }
38
45
 
39
- afterEach(() => {
46
+ afterEach(async () => {
47
+ // Release every store before its directory goes, so the sqlite handles are
48
+ // closed when the temp roots are removed (and no test inherits a store).
49
+ await releaseAllSessionStores();
40
50
  for (const dir of tempRoots.splice(0)) {
41
51
  rmSync(dir, { recursive: true, force: true });
42
52
  }
43
53
  });
44
54
 
45
- describe("resolvePlatformOptions", () => {
46
- it("derives stateRoot under the workspace volume (not $HOME)", () => {
47
- const workspaceRootDir = freshWorkspaceRoot();
48
- const opts = resolvePlatformOptions("ses-123", workspaceRootDir);
49
-
50
- expect(opts.stateRoot).toBe(
51
- join(workspaceRootDir, ".stigmer", "cursor-sdk-state", "ses-123"),
52
- );
53
- expect(opts.stateRoot.startsWith(workspaceRootDir)).toBe(true);
54
- // Created eagerly to prevent ENOENT on first SDK write.
55
- expect(existsSync(opts.stateRoot!)).toBe(true);
56
- expect(opts.workspaceRef).toBe("stigmer-session:ses-123");
57
- });
58
-
59
- it("isolates two sessions sharing one workspace volume into distinct stores", () => {
60
- const workspaceRootDir = freshWorkspaceRoot();
61
- const a = resolvePlatformOptions("ses-aaa", workspaceRootDir);
62
- const b = resolvePlatformOptions("ses-bbb", workspaceRootDir);
63
-
64
- expect(a.stateRoot).not.toBe(b.stateRoot);
65
- expect(a.workspaceRef).not.toBe(b.workspaceRef);
66
- // Both live under the same shared volume but in session-keyed subdirs.
67
- expect(a.stateRoot!.startsWith(join(workspaceRootDir, ".stigmer"))).toBe(true);
68
- expect(b.stateRoot!.startsWith(join(workspaceRootDir, ".stigmer"))).toBe(true);
69
- });
70
-
71
- it("throws on an empty sessionId (would collide across sessions on a shared volume)", () => {
72
- const workspaceRootDir = freshWorkspaceRoot();
73
- expect(() => resolvePlatformOptions("", workspaceRootDir)).toThrow(/sessionId is required/);
74
- });
55
+ function lastCreateOptions(): AgentOptions {
56
+ return vi.mocked(Agent.create).mock.calls.at(-1)![0];
57
+ }
75
58
 
76
- it("throws on an empty workspaceRootDir (state must live on the durable volume)", () => {
77
- expect(() => resolvePlatformOptions("ses-123", "")).toThrow(/workspaceRootDir is required/);
78
- });
79
- });
59
+ function lastResumeCall(): [string, Partial<AgentOptions> | undefined] {
60
+ return vi.mocked(Agent.resume).mock.calls.at(-1)! as [string, Partial<AgentOptions> | undefined];
61
+ }
80
62
 
81
63
  // ---------------------------------------------------------------------------
82
- // Workspace binding across create/resume
64
+ // The local options across create/resume
83
65
  //
84
66
  // Regression: Agent.resume() does not persist local.cwd. When resumeAgent()
85
67
  // omitted it, the SDK fell back to process.cwd() — re-rooting the resumed
@@ -87,17 +69,33 @@ describe("resolvePlatformOptions", () => {
87
69
  // setting source (the .cursor/hooks.json carrying the HITL approval hook)
88
70
  // from that wrong directory. Result: on every resumed turn, file edits and
89
71
  // shell commands ran unguarded with no approval card (observed in production
90
- // execution aex_01ktr5na07f5xtmn0dz3mfjtdp).
72
+ // execution aex_01ktr5na07f5xtmn0dz3mfjtdp). The same rule now covers the
73
+ // store (#1053): a resume without it would look the agent up in a default
74
+ // store derived from cwd, not the one the session's records are in.
91
75
  // ---------------------------------------------------------------------------
92
76
 
93
- describe("workspace binding on create/resume", () => {
77
+ describe("local options on create/resume", () => {
94
78
  const baseOptions = {
95
79
  apiKey: "key",
96
80
  sessionId: "ses-cwd-test",
97
81
  model: "gpt-test",
98
82
  };
99
83
 
100
- it("createAgent passes the single workspace dir as local.cwd", async () => {
84
+ it("createAgent passes the single workspace dir as local.cwd, with no dirs", async () => {
85
+ const workspaceRootDir = freshWorkspaceRoot();
86
+ await createAgent({
87
+ ...baseOptions,
88
+ workspaceDirs: ["/work/repo-a"],
89
+ workspaceRootDir,
90
+ });
91
+
92
+ const { local } = lastCreateOptions();
93
+ expect(local?.cwd).toBe("/work/repo-a");
94
+ expect(local).not.toHaveProperty("dirs");
95
+ expect(local?.settingSources).toContain("project");
96
+ });
97
+
98
+ it("createAgent opens the session's real SQLite store under the workspace volume and passes it as local.store", async () => {
101
99
  const workspaceRootDir = freshWorkspaceRoot();
102
100
  await createAgent({
103
101
  ...baseOptions,
@@ -105,13 +103,37 @@ describe("workspace binding on create/resume", () => {
105
103
  workspaceRootDir,
106
104
  });
107
105
 
108
- const callOptions = vi.mocked(Agent.create).mock.calls.at(-1)![0] as any;
109
- expect(callOptions.local.cwd).toBe("/work/repo-a");
110
- expect(callOptions.local.settingSources).toContain("project");
106
+ const store = lastCreateOptions().local?.store;
107
+ expect(store).toBeInstanceOf(SqliteLocalAgentStore);
108
+ const sqlite = store as SqliteLocalAgentStore;
109
+ expect(sqlite.workspaceRef).toBe("stigmer-session:ses-cwd-test");
110
+ expect(sqlite.stateRoot).toBe(join(workspaceRootDir, ".stigmer", "cursor-sdk-state", "ses-cwd-test"));
111
+ // The SDK's layout lands where the runner said, not under $HOME.
112
+ expect(existsSync(sqlite.stateRoot)).toBe(true);
111
113
  });
112
114
 
113
- it("resumeAgent re-supplies local.cwd (not persisted by Agent.resume)", async () => {
115
+ it("createAgent pins the SDK's own retries off (the runner is the retry authority)", async () => {
114
116
  const workspaceRootDir = freshWorkspaceRoot();
117
+ await createAgent({
118
+ ...baseOptions,
119
+ workspaceDirs: ["/work/repo-a"],
120
+ workspaceRootDir,
121
+ });
122
+
123
+ // 1.0.31 defaults this to true for headless embedders; an absent field
124
+ // would silently stack the SDK's transport retries under the runner's.
125
+ expect(lastCreateOptions().local?.enableAgentRetries).toBe(false);
126
+ });
127
+
128
+ it("resumeAgent re-supplies cwd, settingSources, store and the retry pin (nothing under local survives Agent.resume)", async () => {
129
+ const workspaceRootDir = freshWorkspaceRoot();
130
+ await createAgent({
131
+ ...baseOptions,
132
+ workspaceDirs: ["/work/repo-a"],
133
+ workspaceRootDir,
134
+ });
135
+ const createdWith = lastCreateOptions().local?.store;
136
+
115
137
  await resumeAgent({
116
138
  ...baseOptions,
117
139
  agentId: "agent-123",
@@ -119,25 +141,31 @@ describe("workspace binding on create/resume", () => {
119
141
  workspaceRootDir,
120
142
  });
121
143
 
122
- const [agentId, callOptions] = vi.mocked(Agent.resume).mock.calls.at(-1)! as [string, any];
144
+ const [agentId, callOptions] = lastResumeCall();
123
145
  expect(agentId).toBe("agent-123");
124
- // The load-bearing assertion: without cwd the SDK re-roots the agent at
125
- // process.cwd() and the project HITL hook never loads on resumed turns.
126
- expect(callOptions.local.cwd).toBe("/work/repo-a");
127
- expect(callOptions.local.settingSources).toContain("project");
146
+ // The load-bearing assertions: without cwd the SDK re-roots the agent at
147
+ // process.cwd() and the project HITL hook never loads on resumed turns;
148
+ // without the SAME store it looks the agent up somewhere else.
149
+ expect(callOptions?.local?.cwd).toBe("/work/repo-a");
150
+ expect(callOptions?.local?.settingSources).toContain("project");
151
+ expect(callOptions?.local?.store).toBe(createdWith);
152
+ expect(callOptions?.local?.enableAgentRetries).toBe(false);
128
153
  });
129
154
 
130
- it("resumeAgent passes multiple workspace dirs as an array cwd", async () => {
155
+ it("passes a multi-root workspace as cwd (the first dir) plus dirs (the rest)", async () => {
131
156
  const workspaceRootDir = freshWorkspaceRoot();
132
157
  await resumeAgent({
133
158
  ...baseOptions,
134
159
  agentId: "agent-456",
135
- workspaceDirs: ["/work/repo-a", "/work/repo-b"],
160
+ workspaceDirs: ["/work/repo-a", "/work/repo-b", "/work/repo-c"],
136
161
  workspaceRootDir,
137
162
  });
138
163
 
139
- const callOptions = vi.mocked(Agent.resume).mock.calls.at(-1)![1] as any;
140
- expect(callOptions.local.cwd).toEqual(["/work/repo-a", "/work/repo-b"]);
164
+ const [, callOptions] = lastResumeCall();
165
+ // The SDK merges cwd-first with duplicates dropped, so this is the array
166
+ // the SDK took whole at 1.0.13, in the same order.
167
+ expect(callOptions?.local?.cwd).toBe("/work/repo-a");
168
+ expect(callOptions?.local?.dirs).toEqual(["/work/repo-b", "/work/repo-c"]);
141
169
  });
142
170
  });
143
171