pi-cursor-sdk 0.1.55 → 0.1.57
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/CHANGELOG.md +60 -2
- package/README.md +100 -28
- package/docs/cursor-live-smoke-checklist.md +7 -7
- package/docs/cursor-model-ux-spec.md +48 -41
- package/docs/cursor-native-tool-replay.md +4 -4
- package/docs/cursor-native-tool-visual-audit.md +1 -1
- package/docs/cursor-testing-lessons.md +20 -7
- package/docs/cursor-tool-surfaces.md +13 -2
- package/docs/platform-smoke-implementation.md +220 -0
- package/docs/platform-smoke.md +183 -247
- package/package.json +39 -7
- package/platform-smoke.config.mjs +5 -1
- package/scripts/cloud-runtime-smoke.d.mts +3 -0
- package/scripts/cloud-runtime-smoke.mjs +502 -0
- package/scripts/debug-provider-events.mjs +7 -2
- package/scripts/isolated-cursor-smoke.sh +4 -6
- package/scripts/lib/cursor-child-process.d.mts +1 -0
- package/scripts/lib/cursor-child-process.mjs +137 -7
- package/scripts/lib/local-resume-smoke-harness.mjs +543 -0
- package/scripts/local-resume-cleanup-smoke.mjs +108 -0
- package/scripts/local-resume-smoke.d.mts +1 -0
- package/scripts/local-resume-smoke.mjs +642 -0
- package/scripts/platform-smoke/artifact-anchored-extract.d.mts +10 -0
- package/scripts/platform-smoke/artifact-anchored-extract.mjs +111 -0
- package/scripts/platform-smoke/artifact-bundle-chunk.mjs +57 -0
- package/scripts/platform-smoke/artifact-bundle-contract.mjs +34 -0
- package/scripts/platform-smoke/artifact-fs-safety.mjs +311 -0
- package/scripts/platform-smoke/artifact-openat-extract.c +335 -0
- package/scripts/platform-smoke/artifact-secrets.mjs +155 -0
- package/scripts/platform-smoke/artifacts.mjs +293 -65
- package/scripts/platform-smoke/card-detect.mjs +16 -4
- package/scripts/platform-smoke/crabbox-runner.mjs +45 -3
- package/scripts/platform-smoke/doctor.mjs +20 -10
- package/scripts/platform-smoke/live-suite-runner.mjs +18 -57
- package/scripts/platform-smoke/local-resume-runner.mjs +252 -0
- package/scripts/platform-smoke/local-resume-suites.d.mts +15 -0
- package/scripts/platform-smoke/local-resume-suites.mjs +104 -0
- package/scripts/platform-smoke/scenarios.mjs +16 -2
- package/scripts/platform-smoke/target-runtime.mjs +206 -0
- package/scripts/platform-smoke/targets.mjs +33 -141
- package/scripts/platform-smoke/visual-evidence.mjs +6 -7
- package/scripts/platform-smoke/wrapped-line-match.mjs +9 -0
- package/scripts/platform-smoke.mjs +40 -27
- package/scripts/refresh-cursor-model-snapshots.mjs +18 -6
- package/scripts/steering-rpc-smoke.mjs +12 -2
- package/scripts/tmux-live-smoke.sh +3 -5
- package/shared/cursor-cloud-lifecycle-constants.d.mts +3 -0
- package/shared/cursor-cloud-lifecycle-constants.mjs +7 -0
- package/shared/cursor-sensitive-text.mjs +7 -1
- package/src/context.ts +5 -2
- package/src/cursor-agents-context-registration.ts +7 -0
- package/src/cursor-agents-context.ts +3 -1
- package/src/cursor-api-key.ts +15 -1
- package/src/cursor-bridge-contract.ts +3 -0
- package/src/cursor-cloud-lifecycle.ts +733 -0
- package/src/cursor-cloud-options.ts +206 -0
- package/src/cursor-cloud-reporting.ts +246 -0
- package/src/cursor-config.ts +659 -0
- package/src/cursor-display-only-trace.ts +14 -0
- package/src/cursor-display-text.ts +8 -2
- package/src/cursor-durable-fs.ts +49 -0
- package/src/cursor-fallback-models.generated.ts +2045 -485
- package/src/cursor-live-run-accounting.ts +7 -1
- package/src/cursor-live-run-coordinator.ts +1 -0
- package/src/cursor-pi-tool-bridge-run.ts +14 -4
- package/src/cursor-provider-errors.ts +31 -5
- package/src/cursor-provider-live-run-drain.ts +5 -0
- package/src/cursor-provider-run-finalizer.ts +37 -28
- package/src/cursor-provider-run-outcome.ts +7 -2
- package/src/cursor-provider-turn-finalize.ts +77 -7
- package/src/cursor-provider-turn-prepare.ts +228 -12
- package/src/cursor-provider-turn-runner.ts +42 -13
- package/src/cursor-provider-turn-send.ts +59 -16
- package/src/cursor-provider-turn-types.ts +44 -10
- package/src/cursor-runtime-state.ts +478 -0
- package/src/cursor-sdk-event-debug.ts +46 -6
- package/src/cursor-sdk-process-error-guard.ts +101 -30
- package/src/cursor-session-agent-cleanup.ts +328 -0
- package/src/cursor-session-agent-resume.ts +439 -0
- package/src/cursor-session-agent.ts +109 -13
- package/src/cursor-session-scope.ts +35 -2
- package/src/cursor-session-send-policy.ts +1 -1
- package/src/cursor-skill-tool.ts +30 -11
- package/src/cursor-state.ts +112 -69
- package/src/cursor-tool-manifest.ts +1 -1
- package/src/cursor-usage-accounting.ts +14 -4
- package/src/index.ts +11 -2
- package/src/model-discovery.ts +10 -56
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
appendFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import {
|
|
17
|
+
CHILD_PROCESS_TREE_SPAWN_OPTIONS,
|
|
18
|
+
terminateChild,
|
|
19
|
+
} from "./cursor-child-process.mjs";
|
|
20
|
+
import { buildCursorSmokeEnv } from "./cursor-smoke-env.mjs";
|
|
21
|
+
import { scrubSensitiveText } from "../../shared/cursor-sensitive-text.mjs";
|
|
22
|
+
|
|
23
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
24
|
+
const CLOUD_RUNTIME_ENV_NAMES = [
|
|
25
|
+
"PI_CURSOR_CLOUD_ACK",
|
|
26
|
+
"PI_CURSOR_CLOUD_ALLOW_LOCAL_STATE",
|
|
27
|
+
"PI_CURSOR_CLOUD_CONTEXT",
|
|
28
|
+
"PI_CURSOR_CLOUD_REPO",
|
|
29
|
+
"PI_CURSOR_CLOUD_BRANCH",
|
|
30
|
+
"PI_CURSOR_CLOUD_DIRECT_PUSH",
|
|
31
|
+
"PI_CURSOR_CLOUD_ENV",
|
|
32
|
+
"PI_CURSOR_CLOUD_ENV_FROM_FILES",
|
|
33
|
+
"PI_CURSOR_CLOUD_ENV_TYPE",
|
|
34
|
+
"PI_CURSOR_CLOUD_ENV_NAME",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function scrubSmokeText(value) {
|
|
38
|
+
return scrubSensitiveText(String(value), process.env.CURSOR_API_KEY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class SmokeFailure extends Error {
|
|
42
|
+
constructor(message, details = "") {
|
|
43
|
+
super(message);
|
|
44
|
+
this.details = details;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function fail(message, details = "") {
|
|
49
|
+
throw new SmokeFailure(message, details);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function reportFailure(error) {
|
|
53
|
+
console.error(
|
|
54
|
+
scrubSmokeText(`[local-resume-smoke] FAIL: ${error instanceof Error ? error.message : String(error)}`),
|
|
55
|
+
);
|
|
56
|
+
if (error instanceof SmokeFailure && error.details)
|
|
57
|
+
console.error(scrubSmokeText(error.details));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function assertExactStringArray(label, actual, expected) {
|
|
61
|
+
const normalizedActual = [...(actual ?? [])].sort((a, b) =>
|
|
62
|
+
a.localeCompare(b),
|
|
63
|
+
);
|
|
64
|
+
const normalizedExpected = [...expected].sort((a, b) => a.localeCompare(b));
|
|
65
|
+
if (JSON.stringify(normalizedActual) !== JSON.stringify(normalizedExpected)) {
|
|
66
|
+
fail(
|
|
67
|
+
`${label} mismatch`,
|
|
68
|
+
JSON.stringify(
|
|
69
|
+
{ expected: normalizedExpected, actual: normalizedActual },
|
|
70
|
+
null,
|
|
71
|
+
2,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findPiCommand() {
|
|
78
|
+
const cli = join(
|
|
79
|
+
root,
|
|
80
|
+
"node_modules",
|
|
81
|
+
"@earendil-works",
|
|
82
|
+
"pi-coding-agent",
|
|
83
|
+
"dist",
|
|
84
|
+
"cli.js",
|
|
85
|
+
);
|
|
86
|
+
if (existsSync(cli)) return { command: process.execPath, argsPrefix: [cli] };
|
|
87
|
+
const local = join(
|
|
88
|
+
root,
|
|
89
|
+
"node_modules",
|
|
90
|
+
".bin",
|
|
91
|
+
process.platform === "win32" ? "pi.cmd" : "pi",
|
|
92
|
+
);
|
|
93
|
+
return {
|
|
94
|
+
command: existsSync(local)
|
|
95
|
+
? local
|
|
96
|
+
: process.platform === "win32"
|
|
97
|
+
? "pi.cmd"
|
|
98
|
+
: "pi",
|
|
99
|
+
argsPrefix: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildLocalResumeSmokeEnv(
|
|
104
|
+
artifactDir,
|
|
105
|
+
{
|
|
106
|
+
baseEnv = process.env,
|
|
107
|
+
bridge = false,
|
|
108
|
+
exposeBuiltinTools = false,
|
|
109
|
+
localResumeEnv = "on",
|
|
110
|
+
} = {},
|
|
111
|
+
) {
|
|
112
|
+
const agentDir = join(artifactDir, "agent");
|
|
113
|
+
mkdirSync(agentDir, { recursive: true });
|
|
114
|
+
const env = buildCursorSmokeEnv({
|
|
115
|
+
baseEnv,
|
|
116
|
+
settingSources: "none",
|
|
117
|
+
bridge,
|
|
118
|
+
nativeToolDisplay: false,
|
|
119
|
+
registerNativeTools: false,
|
|
120
|
+
exposeBuiltinTools,
|
|
121
|
+
eventDebugDir: join(artifactDir, "debug"),
|
|
122
|
+
});
|
|
123
|
+
const resumeMode =
|
|
124
|
+
localResumeEnv === true
|
|
125
|
+
? "on"
|
|
126
|
+
: localResumeEnv === false
|
|
127
|
+
? "unset"
|
|
128
|
+
: localResumeEnv;
|
|
129
|
+
for (const name of CLOUD_RUNTIME_ENV_NAMES) delete env[name];
|
|
130
|
+
delete env.PI_CURSOR_LOCAL_RESUME;
|
|
131
|
+
if (resumeMode === "on") env.PI_CURSOR_LOCAL_RESUME = "1";
|
|
132
|
+
else if (resumeMode === "off") env.PI_CURSOR_LOCAL_RESUME = "0";
|
|
133
|
+
else if (resumeMode !== "unset")
|
|
134
|
+
fail(`unknown localResumeEnv mode: ${String(localResumeEnv)}`);
|
|
135
|
+
return {
|
|
136
|
+
...env,
|
|
137
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
138
|
+
PI_CURSOR_RUNTIME: "local",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function startRpc({
|
|
143
|
+
artifactDir,
|
|
144
|
+
sessionDir,
|
|
145
|
+
sessionId,
|
|
146
|
+
bridge = false,
|
|
147
|
+
exposeBuiltinTools = false,
|
|
148
|
+
extraExtensions = [],
|
|
149
|
+
localResumeEnv = "on",
|
|
150
|
+
baseEnv = process.env,
|
|
151
|
+
}) {
|
|
152
|
+
const model =
|
|
153
|
+
process.env.CURSOR_LOCAL_RESUME_SMOKE_MODEL || "cursor/composer-2-5:slow";
|
|
154
|
+
const workspaceDir = join(artifactDir, "workspace");
|
|
155
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
156
|
+
const pi = findPiCommand();
|
|
157
|
+
const extensionPath = resolve(process.env.CURSOR_LOCAL_RESUME_SMOKE_EXTENSION_PATH || root);
|
|
158
|
+
if (!existsSync(extensionPath)) fail(`local resume extension path does not exist: ${extensionPath}`);
|
|
159
|
+
appendFileSync(join(artifactDir, "runtime-launches.jsonl"), `${JSON.stringify({
|
|
160
|
+
command: pi.command,
|
|
161
|
+
extensionPath,
|
|
162
|
+
workspaceDir,
|
|
163
|
+
sessionDir,
|
|
164
|
+
sessionId,
|
|
165
|
+
})}\n`);
|
|
166
|
+
const child = spawn(
|
|
167
|
+
pi.command,
|
|
168
|
+
[
|
|
169
|
+
...pi.argsPrefix,
|
|
170
|
+
"--mode",
|
|
171
|
+
"rpc",
|
|
172
|
+
"-e",
|
|
173
|
+
extensionPath,
|
|
174
|
+
...extraExtensions.flatMap((path) => ["-e", path]),
|
|
175
|
+
"--model",
|
|
176
|
+
model,
|
|
177
|
+
"--cursor-runtime",
|
|
178
|
+
"local",
|
|
179
|
+
"--approve",
|
|
180
|
+
"--session-dir",
|
|
181
|
+
sessionDir,
|
|
182
|
+
"--session-id",
|
|
183
|
+
sessionId,
|
|
184
|
+
],
|
|
185
|
+
{
|
|
186
|
+
cwd: workspaceDir,
|
|
187
|
+
env: buildLocalResumeSmokeEnv(artifactDir, {
|
|
188
|
+
baseEnv,
|
|
189
|
+
bridge,
|
|
190
|
+
exposeBuiltinTools,
|
|
191
|
+
localResumeEnv,
|
|
192
|
+
}),
|
|
193
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
194
|
+
...CHILD_PROCESS_TREE_SPAWN_OPTIONS,
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
let stdoutBuffer = "";
|
|
198
|
+
let stderr = "";
|
|
199
|
+
const events = [];
|
|
200
|
+
const pending = new Map();
|
|
201
|
+
let requestId = 0;
|
|
202
|
+
child.stdout.setEncoding("utf8");
|
|
203
|
+
child.stderr.setEncoding("utf8");
|
|
204
|
+
child.stderr.on("data", (chunk) => {
|
|
205
|
+
stderr += chunk;
|
|
206
|
+
});
|
|
207
|
+
child.stdout.on("data", (chunk) => {
|
|
208
|
+
stdoutBuffer += chunk;
|
|
209
|
+
let newlineIndex = stdoutBuffer.indexOf("\n");
|
|
210
|
+
while (newlineIndex >= 0) {
|
|
211
|
+
const line = stdoutBuffer.slice(0, newlineIndex);
|
|
212
|
+
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
213
|
+
newlineIndex = stdoutBuffer.indexOf("\n");
|
|
214
|
+
if (!line.trim()) continue;
|
|
215
|
+
let message;
|
|
216
|
+
try {
|
|
217
|
+
message = JSON.parse(line);
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (message.type === "response" && pending.has(message.id)) {
|
|
222
|
+
const request = pending.get(message.id);
|
|
223
|
+
pending.delete(message.id);
|
|
224
|
+
clearTimeout(request.timer);
|
|
225
|
+
request.resolve(message);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
events.push(message);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
const send = (type, extra = {}, timeoutMs = 120000) =>
|
|
232
|
+
new Promise((resolveRequest, rejectRequest) => {
|
|
233
|
+
const id = `local_resume_smoke_${++requestId}`;
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
pending.delete(id);
|
|
236
|
+
rejectRequest(
|
|
237
|
+
new Error(`timeout waiting for ${type}. Stderr: ${stderr}`),
|
|
238
|
+
);
|
|
239
|
+
}, timeoutMs);
|
|
240
|
+
pending.set(id, {
|
|
241
|
+
resolve: resolveRequest,
|
|
242
|
+
reject: rejectRequest,
|
|
243
|
+
timer,
|
|
244
|
+
});
|
|
245
|
+
child.stdin.write(`${JSON.stringify({ id, type, ...extra })}\n`);
|
|
246
|
+
});
|
|
247
|
+
const stop = async () => {
|
|
248
|
+
for (const request of pending.values()) clearTimeout(request.timer);
|
|
249
|
+
pending.clear();
|
|
250
|
+
await terminateChild(child, { graceMs: 15_000 });
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
events,
|
|
254
|
+
send,
|
|
255
|
+
stop,
|
|
256
|
+
get stderr() {
|
|
257
|
+
return stderr;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function withRpc(options, run) {
|
|
263
|
+
const rpc = startRpc(options);
|
|
264
|
+
try {
|
|
265
|
+
return await run(rpc);
|
|
266
|
+
} finally {
|
|
267
|
+
await rpc.stop();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function waitForAgentEnd(rpc, fromIndex, timeoutMs) {
|
|
272
|
+
const started = Date.now();
|
|
273
|
+
while (Date.now() - started < timeoutMs) {
|
|
274
|
+
if (rpc.events.slice(fromIndex).some((event) => event.type === "agent_end"))
|
|
275
|
+
return;
|
|
276
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
277
|
+
}
|
|
278
|
+
throw new Error(`timeout waiting for agent_end. Stderr: ${rpc.stderr}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function waitForFile(path, timeoutMs, rpc) {
|
|
282
|
+
const started = Date.now();
|
|
283
|
+
while (Date.now() - started < timeoutMs) {
|
|
284
|
+
if (existsSync(path)) return;
|
|
285
|
+
if (rpc?.events.some((event) => event.type === "agent_end"))
|
|
286
|
+
fail(`agent ended before ${path} existed`, rpc.stderr);
|
|
287
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
288
|
+
}
|
|
289
|
+
fail(`timeout waiting for ${path}`, rpc?.stderr ?? "");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function metadataFiles(artifactDir) {
|
|
293
|
+
const files = [];
|
|
294
|
+
const stack = [join(artifactDir, "debug")];
|
|
295
|
+
while (stack.length > 0) {
|
|
296
|
+
const current = stack.pop();
|
|
297
|
+
try {
|
|
298
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
299
|
+
const path = join(current, entry.name);
|
|
300
|
+
if (entry.isDirectory()) stack.push(path);
|
|
301
|
+
else if (entry.name === "metadata.json") files.push(path);
|
|
302
|
+
}
|
|
303
|
+
} catch {}
|
|
304
|
+
}
|
|
305
|
+
return files.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readMetadataSince(artifactDir, seenPaths) {
|
|
309
|
+
return metadataFiles(artifactDir)
|
|
310
|
+
.filter((metadataPath) => !seenPaths.has(metadataPath))
|
|
311
|
+
.map((metadataPath) => ({
|
|
312
|
+
metadataPath,
|
|
313
|
+
metadata: JSON.parse(readFileSync(metadataPath, "utf8")),
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function readLastAssistantText(rpc) {
|
|
318
|
+
const started = Date.now();
|
|
319
|
+
let lastResponse;
|
|
320
|
+
while (Date.now() - started < 10000) {
|
|
321
|
+
lastResponse = await rpc.send("get_last_assistant_text", {}, 120000);
|
|
322
|
+
if (!lastResponse.success)
|
|
323
|
+
fail("failed to read last assistant text", lastResponse.error);
|
|
324
|
+
const text =
|
|
325
|
+
typeof lastResponse.data?.text === "string" ? lastResponse.data.text : "";
|
|
326
|
+
if (text.trim().length > 0) return text;
|
|
327
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
328
|
+
}
|
|
329
|
+
return typeof lastResponse?.data?.text === "string"
|
|
330
|
+
? lastResponse.data.text
|
|
331
|
+
: "";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function rpcData(rpc, type, extra = {}, timeoutMs = 120000) {
|
|
335
|
+
const response = await rpc.send(type, extra, timeoutMs);
|
|
336
|
+
if (!response.success)
|
|
337
|
+
fail(`${type} RPC failed`, response.error ?? JSON.stringify(response));
|
|
338
|
+
return response.data ?? {};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function getState(rpc) {
|
|
342
|
+
return rpcData(rpc, "get_state");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function getEntries(rpc) {
|
|
346
|
+
return rpcData(rpc, "get_entries");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function resumeEntries(entries) {
|
|
350
|
+
return (entries.entries ?? []).filter(
|
|
351
|
+
(entry) =>
|
|
352
|
+
entry?.type === "custom" &&
|
|
353
|
+
entry.customType === "cursor-sdk-agent-resume",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function latestResumeEntry(entries) {
|
|
358
|
+
return resumeEntries(entries).at(-1)?.data;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function cleanupEntries(entries) {
|
|
362
|
+
return (entries.entries ?? []).filter(
|
|
363
|
+
(entry) =>
|
|
364
|
+
entry?.type === "custom" &&
|
|
365
|
+
entry.customType === "cursor-sdk-agent-cleanup",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function waitForCleanupEntryCount(rpc, count, timeoutMs) {
|
|
370
|
+
const started = Date.now();
|
|
371
|
+
while (Date.now() - started < timeoutMs) {
|
|
372
|
+
const entries = await getEntries(rpc);
|
|
373
|
+
const cleanups = cleanupEntries(entries);
|
|
374
|
+
if (cleanups.length >= count) return cleanups;
|
|
375
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
|
376
|
+
}
|
|
377
|
+
fail(`timeout waiting for ${count} cleanup entries`, rpc.stderr);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function resumeEntryCount(entries) {
|
|
381
|
+
return resumeEntries(entries).length;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function compactionEntryCount(entries) {
|
|
385
|
+
return (entries.entries ?? []).filter((entry) => entry?.type === "compaction")
|
|
386
|
+
.length;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function entryText(entry) {
|
|
390
|
+
const content = entry?.message?.content;
|
|
391
|
+
if (typeof content === "string") return content;
|
|
392
|
+
if (Array.isArray(content))
|
|
393
|
+
return content
|
|
394
|
+
.map((part) => (typeof part === "string" ? part : (part?.text ?? "")))
|
|
395
|
+
.join("\n");
|
|
396
|
+
return "";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function userEntryContaining(entries, text) {
|
|
400
|
+
return (entries.entries ?? []).find(
|
|
401
|
+
(entry) =>
|
|
402
|
+
entry?.type === "message" &&
|
|
403
|
+
entry.message?.role === "user" &&
|
|
404
|
+
entryText(entry).includes(text),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export function assistantEntryContaining(entries, text) {
|
|
409
|
+
return (entries.entries ?? []).find(
|
|
410
|
+
(entry) =>
|
|
411
|
+
entry?.type === "message" &&
|
|
412
|
+
entry.message?.role === "assistant" &&
|
|
413
|
+
entryText(entry).includes(text),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function writeTreeCommandExtension(artifactRoot) {
|
|
418
|
+
const extensionPath = join(artifactRoot, "local-resume-tree-extension.mjs");
|
|
419
|
+
writeFileSync(
|
|
420
|
+
extensionPath,
|
|
421
|
+
`export default function(pi) {\n pi.registerCommand("local_resume_tree_go", {\n description: "local resume tree proof",\n handler: async (args, ctx) => {\n const text = String(args || "");\n const split = text.indexOf(" ");\n const targetId = split >= 0 ? text.slice(0, split) : text;\n const message = split >= 0 ? text.slice(split + 1) : "";\n await ctx.navigateTree(targetId, { summarize: false });\n if (message) pi.sendUserMessage(message);\n },\n });\n}\n`,
|
|
422
|
+
);
|
|
423
|
+
return extensionPath;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function takeLatestMetadata(artifactDir, seenMetadata) {
|
|
427
|
+
const metadata = readMetadataSince(artifactDir, seenMetadata);
|
|
428
|
+
for (const item of metadata) seenMetadata.add(item.metadataPath);
|
|
429
|
+
const latest = metadata.at(-1);
|
|
430
|
+
if (!latest) fail("no new metadata was written", artifactDir);
|
|
431
|
+
return {
|
|
432
|
+
metadataPath: latest.metadataPath,
|
|
433
|
+
metadata: latest.metadata,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function promptAndRead({
|
|
438
|
+
rpc,
|
|
439
|
+
artifactDir,
|
|
440
|
+
message,
|
|
441
|
+
timeoutMs,
|
|
442
|
+
seenMetadata,
|
|
443
|
+
}) {
|
|
444
|
+
const eventStart = rpc.events.length;
|
|
445
|
+
await rpc.send("prompt", { message }, timeoutMs);
|
|
446
|
+
await waitForAgentEnd(rpc, eventStart, timeoutMs);
|
|
447
|
+
const text = await readLastAssistantText(rpc);
|
|
448
|
+
return {
|
|
449
|
+
text,
|
|
450
|
+
events: rpc.events.slice(eventStart),
|
|
451
|
+
...takeLatestMetadata(artifactDir, seenMetadata),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function promptAbortAndRead({
|
|
456
|
+
rpc,
|
|
457
|
+
artifactDir,
|
|
458
|
+
message,
|
|
459
|
+
markerPath,
|
|
460
|
+
timeoutMs,
|
|
461
|
+
seenMetadata,
|
|
462
|
+
}) {
|
|
463
|
+
const eventStart = rpc.events.length;
|
|
464
|
+
await rpc.send("prompt", { message }, timeoutMs);
|
|
465
|
+
await waitForFile(markerPath, timeoutMs, rpc);
|
|
466
|
+
await rpcData(rpc, "abort", {}, 120000);
|
|
467
|
+
await waitForAgentEnd(rpc, eventStart, timeoutMs);
|
|
468
|
+
return takeLatestMetadata(artifactDir, seenMetadata);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function assertTurnMetadata(label, turn, expected) {
|
|
472
|
+
const meta = turn.metadata.providerMeta ?? {};
|
|
473
|
+
if (meta.runtime === "cloud")
|
|
474
|
+
fail(`${label} unexpectedly recorded cloud runtime`, turn.metadataPath);
|
|
475
|
+
if (meta.localResume !== true)
|
|
476
|
+
fail(`${label} did not record localResume=true`, turn.metadataPath);
|
|
477
|
+
if (meta.resumedAgent !== expected.resumedAgent) {
|
|
478
|
+
fail(
|
|
479
|
+
`${label} resumedAgent mismatch`,
|
|
480
|
+
JSON.stringify(
|
|
481
|
+
{
|
|
482
|
+
expected: expected.resumedAgent,
|
|
483
|
+
actual: meta.resumedAgent,
|
|
484
|
+
metadataPath: turn.metadataPath,
|
|
485
|
+
},
|
|
486
|
+
null,
|
|
487
|
+
2,
|
|
488
|
+
),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
if (!turn.metadata.run?.agentId?.startsWith?.("agent-"))
|
|
492
|
+
fail(`${label} did not record local agent id`, turn.metadataPath);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function assertNotResumedFrom(label, turn, agentId) {
|
|
496
|
+
assertTurnMetadata(label, turn, { resumedAgent: false });
|
|
497
|
+
if (turn.metadata.run.agentId === agentId)
|
|
498
|
+
fail(
|
|
499
|
+
`${label} reused original local SDK agent`,
|
|
500
|
+
JSON.stringify(
|
|
501
|
+
{ original: agentId, actual: turn.metadata.run.agentId },
|
|
502
|
+
null,
|
|
503
|
+
2,
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function createRunContext(prefix) {
|
|
509
|
+
const configuredRoot = process.env.CURSOR_LOCAL_RESUME_SMOKE_ARTIFACT_DIR;
|
|
510
|
+
const artifactRoot = configuredRoot ? resolve(configuredRoot) : mkdtempSync(join(tmpdir(), prefix));
|
|
511
|
+
mkdirSync(artifactRoot, { recursive: true });
|
|
512
|
+
return {
|
|
513
|
+
artifactRoot,
|
|
514
|
+
sessionDir: join(artifactRoot, "sessions"),
|
|
515
|
+
sessionId: `local-resume-${Date.now()}`,
|
|
516
|
+
seenMetadata: new Set(),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function parseTimeout() {
|
|
521
|
+
const timeoutMs = Number(
|
|
522
|
+
process.env.CURSOR_LOCAL_RESUME_SMOKE_TIMEOUT_MS || 300000,
|
|
523
|
+
);
|
|
524
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
525
|
+
fail("CURSOR_LOCAL_RESUME_SMOKE_TIMEOUT_MS must be a positive number");
|
|
526
|
+
return timeoutMs;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function cleanupArtifactRoot(artifactRoot) {
|
|
530
|
+
if (process.env.CURSOR_LOCAL_RESUME_SMOKE_KEEP_ARTIFACTS === "1") return;
|
|
531
|
+
try {
|
|
532
|
+
rmSync(artifactRoot, {
|
|
533
|
+
recursive: true,
|
|
534
|
+
force: true,
|
|
535
|
+
maxRetries: 5,
|
|
536
|
+
retryDelay: 250,
|
|
537
|
+
});
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error(
|
|
540
|
+
scrubSmokeText(`[local-resume-smoke] warning: failed to remove temp artifacts: ${error instanceof Error ? error.message : String(error)}`),
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertExactStringArray,
|
|
3
|
+
assertNotResumedFrom,
|
|
4
|
+
assertTurnMetadata,
|
|
5
|
+
cleanupArtifactRoot,
|
|
6
|
+
createRunContext,
|
|
7
|
+
fail,
|
|
8
|
+
getEntries,
|
|
9
|
+
latestResumeEntry,
|
|
10
|
+
parseTimeout,
|
|
11
|
+
promptAndRead,
|
|
12
|
+
resumeEntries,
|
|
13
|
+
rpcData,
|
|
14
|
+
scrubSmokeText,
|
|
15
|
+
waitForCleanupEntryCount,
|
|
16
|
+
withRpc,
|
|
17
|
+
writeTreeCommandExtension,
|
|
18
|
+
} from "./lib/local-resume-smoke-harness.mjs";
|
|
19
|
+
|
|
20
|
+
export async function runCleanupSmoke() {
|
|
21
|
+
const timeoutMs = parseTimeout();
|
|
22
|
+
const { artifactRoot, sessionDir, sessionId, seenMetadata } = createRunContext("pi-cursor-local-resume-cleanup-smoke-");
|
|
23
|
+
const marker = `LOCAL_CLEANUP_${Date.now()}`;
|
|
24
|
+
const extensionPath = writeTreeCommandExtension(artifactRoot);
|
|
25
|
+
let oldAgentId;
|
|
26
|
+
let newAgentId;
|
|
27
|
+
let oldResumeEntryId;
|
|
28
|
+
console.error(scrubSmokeText(`[local-resume-smoke] artifacts: ${artifactRoot}`));
|
|
29
|
+
try {
|
|
30
|
+
await withRpc({ artifactDir: artifactRoot, sessionDir, sessionId }, async (rpc) => {
|
|
31
|
+
const baseline = await promptAndRead({
|
|
32
|
+
rpc,
|
|
33
|
+
artifactDir: artifactRoot,
|
|
34
|
+
message: `Remember exact cleanup marker ${marker}. Reply exactly CLEANUP_BASELINE_OK.`,
|
|
35
|
+
timeoutMs,
|
|
36
|
+
seenMetadata,
|
|
37
|
+
});
|
|
38
|
+
assertTurnMetadata("cleanup baseline", baseline, { resumedAgent: false });
|
|
39
|
+
oldAgentId = baseline.metadata.run.agentId;
|
|
40
|
+
oldResumeEntryId = resumeEntries(await getEntries(rpc)).at(-1)?.id;
|
|
41
|
+
if (!oldResumeEntryId) fail("cleanup baseline did not persist a resume entry");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await withRpc({ artifactDir: artifactRoot, sessionDir, sessionId, bridge: true, exposeBuiltinTools: true }, async (rpc) => {
|
|
45
|
+
const changedSurface = await promptAndRead({
|
|
46
|
+
rpc,
|
|
47
|
+
artifactDir: artifactRoot,
|
|
48
|
+
message: "From this conversation memory only, what exact LOCAL_CLEANUP marker did I ask you to remember before the tool-surface change? Reply exactly MARKER=<marker> if known, otherwise NO_MARKER. Do not inspect environment variables or files.",
|
|
49
|
+
timeoutMs,
|
|
50
|
+
seenMetadata,
|
|
51
|
+
});
|
|
52
|
+
assertNotResumedFrom("cleanup changed tool surface", changedSurface, oldAgentId);
|
|
53
|
+
newAgentId = changedSurface.metadata.run.agentId;
|
|
54
|
+
const changedHandle = latestResumeEntry(await getEntries(rpc));
|
|
55
|
+
if (!changedHandle?.cleanupCandidateAgentIds?.includes(oldAgentId)) fail("changed tool surface did not record old agent cleanup candidate", JSON.stringify({ oldAgentId, changedHandle }, null, 2));
|
|
56
|
+
|
|
57
|
+
await rpcData(rpc, "prompt", { message: "/cursor-local-resume-cleanup --dry-run" }, timeoutMs);
|
|
58
|
+
let latestCleanup = (await waitForCleanupEntryCount(rpc, 1, timeoutMs)).at(-1)?.data;
|
|
59
|
+
if (latestCleanup?.action !== "dry-run" || latestCleanup.deletedAgentIds?.length || latestCleanup.failedAgentIds?.length) {
|
|
60
|
+
fail("cleanup dry-run did not record a pure dry-run", JSON.stringify(latestCleanup, null, 2));
|
|
61
|
+
}
|
|
62
|
+
assertExactStringArray("cleanup dry-run candidates", latestCleanup.candidateAgentIds, [oldAgentId]);
|
|
63
|
+
|
|
64
|
+
await rpcData(rpc, "prompt", { message: "/cursor-local-resume-cleanup --yes" }, timeoutMs);
|
|
65
|
+
const deleteEntries = await waitForCleanupEntryCount(rpc, 3, timeoutMs);
|
|
66
|
+
const intent = deleteEntries.at(-2)?.data;
|
|
67
|
+
latestCleanup = deleteEntries.at(-1)?.data;
|
|
68
|
+
if (intent?.action !== "delete" || intent.phase !== "intent" || intent.deletedAgentIds?.length || intent.failedAgentIds?.length) {
|
|
69
|
+
fail("cleanup delete did not record intent before its result", JSON.stringify(intent, null, 2));
|
|
70
|
+
}
|
|
71
|
+
assertExactStringArray("cleanup intent candidates", intent.candidateAgentIds, [oldAgentId]);
|
|
72
|
+
if (latestCleanup?.action !== "delete" || latestCleanup.phase !== "result" || latestCleanup.failedAgentIds?.length) {
|
|
73
|
+
fail("cleanup delete did not record a clean result", JSON.stringify(latestCleanup, null, 2));
|
|
74
|
+
}
|
|
75
|
+
assertExactStringArray("cleanup result candidates", latestCleanup.candidateAgentIds, [oldAgentId]);
|
|
76
|
+
assertExactStringArray("cleanup deleted agents", latestCleanup.deletedAgentIds, [oldAgentId]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await withRpc({ artifactDir: artifactRoot, sessionDir, sessionId, bridge: true, exposeBuiltinTools: true }, async (rpc) => {
|
|
80
|
+
const restart = await promptAndRead({
|
|
81
|
+
rpc,
|
|
82
|
+
artifactDir: artifactRoot,
|
|
83
|
+
message: "From this conversation memory only, what exact LOCAL_CLEANUP marker did I ask you to remember before cleanup? Reply exactly MARKER=<marker> if known, otherwise NO_MARKER. Do not inspect environment variables or files.",
|
|
84
|
+
timeoutMs,
|
|
85
|
+
seenMetadata,
|
|
86
|
+
});
|
|
87
|
+
assertTurnMetadata("cleanup preserved current agent", restart, { resumedAgent: true });
|
|
88
|
+
if (restart.metadata.run.agentId !== newAgentId) fail("cleanup deleted or failed to resume the current recorded agent", JSON.stringify({ expected: newAgentId, actual: restart.metadata.run.agentId }, null, 2));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await withRpc({ artifactDir: artifactRoot, sessionDir, sessionId, extraExtensions: [extensionPath] }, async (rpc) => {
|
|
92
|
+
const oldBranch = await promptAndRead({
|
|
93
|
+
rpc,
|
|
94
|
+
artifactDir: artifactRoot,
|
|
95
|
+
message: `/local_resume_tree_go ${oldResumeEntryId} From this conversation memory only, what exact LOCAL_CLEANUP marker did I ask you to remember before old-agent cleanup? Reply exactly MARKER=<marker> if known, otherwise NO_MARKER. Do not inspect environment variables or files.`,
|
|
96
|
+
timeoutMs,
|
|
97
|
+
seenMetadata,
|
|
98
|
+
});
|
|
99
|
+
assertNotResumedFrom("cleanup deleted old agent", oldBranch, oldAgentId);
|
|
100
|
+
if (!oldBranch.text.includes(`MARKER=${marker}`)) fail("cleanup old-branch fallback did not bootstrap marker", JSON.stringify({ expected: `MARKER=${marker}`, actual: oldBranch.text }, null, 2));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
console.log("local-resume-cleanup-smoke-ok");
|
|
104
|
+
console.error(scrubSmokeText(`[local-resume-smoke] cleanup deleted recorded old ${oldAgentId} and preserved current ${newAgentId}`));
|
|
105
|
+
} finally {
|
|
106
|
+
cleanupArtifactRoot(artifactRoot);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function buildLocalResumeSmokeEnv(artifactDir: string, options?: { baseEnv?: NodeJS.ProcessEnv; bridge?: boolean; exposeBuiltinTools?: boolean; localResumeEnv?: boolean | "on" | "unset" | "off" }): NodeJS.ProcessEnv;
|