@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/__test-utils__/contract-subject.js +12 -9
- package/dist/activities/execute-cursor/__test-utils__/contract-subject.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +5 -2
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +5 -2
- package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +11 -27
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +13 -10
- package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +70 -26
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +82 -32
- package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -1
- package/dist/activities/execute-cursor/adapter.d.ts +4 -3
- package/dist/activities/execute-cursor/adapter.js +9 -5
- package/dist/activities/execute-cursor/adapter.js.map +1 -1
- package/dist/activities/execute-cursor/sdk-warmup.d.ts +15 -6
- package/dist/activities/execute-cursor/sdk-warmup.js +15 -6
- package/dist/activities/execute-cursor/sdk-warmup.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +38 -59
- package/dist/activities/execute-cursor/session-lifecycle.js +59 -96
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/session-store.d.ts +100 -0
- package/dist/activities/execute-cursor/session-store.js +157 -0
- package/dist/activities/execute-cursor/session-store.js.map +1 -0
- package/dist/shared/attachment-vision.d.ts +2 -1
- package/dist/shared/attachment-vision.js.map +1 -1
- package/package.json +5 -5
- package/src/activities/execute-cursor/__test-utils__/__tests__/scripted-double.test.ts +31 -7
- package/src/activities/execute-cursor/__test-utils__/contract-subject.ts +10 -8
- package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +5 -2
- package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +20 -28
- package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +94 -38
- package/src/activities/execute-cursor/__tests__/cursor-baseurl-routing.test.ts +10 -8
- package/src/activities/execute-cursor/__tests__/cursor-fetch-interceptor-bypass.test.ts +5 -4
- package/src/activities/execute-cursor/__tests__/cursor-generate-image-live.test.ts +7 -2
- package/src/activities/execute-cursor/__tests__/cursor-hook-protocol-live.test.ts +163 -0
- package/src/activities/execute-cursor/__tests__/cursor-sdk-auth-smoke.test.ts +13 -12
- package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/harness-contract.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/run-wait-arms.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/stream-self-stop-arms.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/unattributed-hook-block.test.ts +4 -1
- package/src/activities/execute-cursor/__tests__/hermetic/workspace-lock-timeout.test.ts +3 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +86 -58
- package/src/activities/execute-cursor/__tests__/session-store.test.ts +220 -0
- package/src/activities/execute-cursor/adapter.ts +9 -5
- package/src/activities/execute-cursor/sdk-warmup.ts +15 -6
- package/src/activities/execute-cursor/session-lifecycle.ts +63 -109
- package/src/activities/execute-cursor/session-store.ts +187 -0
- 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: {
|
|
35
|
-
|
|
36
|
-
|
|
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: {
|
|
54
|
-
|
|
55
|
-
|
|
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: {
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
2
|
+
* Unit tests for the local agent's create/resume options and the
|
|
3
|
+
* transport-recovery wrapper (`session-lifecycle.ts`).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
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("
|
|
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
|
|
109
|
-
expect(
|
|
110
|
-
|
|
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("
|
|
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] =
|
|
144
|
+
const [agentId, callOptions] = lastResumeCall();
|
|
123
145
|
expect(agentId).toBe("agent-123");
|
|
124
|
-
// The load-bearing
|
|
125
|
-
// process.cwd() and the project HITL hook never loads on resumed turns
|
|
126
|
-
|
|
127
|
-
expect(callOptions
|
|
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("
|
|
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 =
|
|
140
|
-
|
|
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
|
|