pi-cursor-sdk 0.1.15 → 0.1.17
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 +56 -1
- package/README.md +20 -8
- package/docs/cursor-live-smoke-checklist.md +267 -0
- package/docs/cursor-model-ux-spec.md +15 -5
- package/docs/cursor-native-tool-replay.md +16 -5
- package/package.json +12 -5
- package/scripts/steering-rpc-smoke.mjs +238 -0
- package/scripts/tmux-live-smoke.sh +418 -0
- package/scripts/validate-smoke-jsonl.mjs +152 -0
- package/src/context.ts +180 -5
- package/src/cursor-bridge-contract.ts +27 -0
- package/src/cursor-edit-diff.ts +11 -0
- package/src/cursor-env-boolean.ts +22 -0
- package/src/cursor-live-run-accounting.ts +65 -0
- package/src/cursor-live-run-coordinator.ts +483 -0
- package/src/cursor-native-tool-display-registration.ts +93 -0
- package/src/cursor-native-tool-display-replay.ts +465 -0
- package/src/cursor-native-tool-display-state.ts +78 -0
- package/src/cursor-native-tool-display-tools.ts +102 -0
- package/src/cursor-native-tool-display.ts +10 -639
- package/src/cursor-partial-content-emitter.ts +121 -0
- package/src/cursor-pi-tool-bridge-abort.ts +133 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
- package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
- package/src/cursor-pi-tool-bridge-run.ts +384 -0
- package/src/cursor-pi-tool-bridge-server.ts +182 -0
- package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
- package/src/cursor-pi-tool-bridge-types.ts +80 -0
- package/src/cursor-pi-tool-bridge.ts +77 -602
- package/src/cursor-provider-live-run-drain.ts +379 -0
- package/src/cursor-provider-turn-coordinator.ts +456 -0
- package/src/cursor-provider.ts +133 -1092
- package/src/cursor-question-tool.ts +7 -2
- package/src/cursor-record-utils.ts +26 -0
- package/src/cursor-sdk-output-filter.ts +100 -0
- package/src/cursor-sensitive-text.ts +37 -0
- package/src/cursor-session-agent.ts +372 -0
- package/src/cursor-session-cwd.ts +14 -19
- package/src/cursor-session-scope.ts +65 -0
- package/src/cursor-state.ts +38 -10
- package/src/cursor-tool-transcript.ts +28 -1229
- package/src/cursor-transcript-tool-formatters.ts +641 -0
- package/src/cursor-transcript-tool-specs.ts +441 -0
- package/src/cursor-transcript-utils.ts +276 -0
- package/src/cursor-usage-accounting.ts +71 -0
- package/src/index.ts +20 -3
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* RPC steering smoke: queue steer after a native-replay tool-use turn completes execution.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
11
|
+
const CHILD_SHUTDOWN_GRACE_MS = 2_000;
|
|
12
|
+
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log(`RPC steering smoke for pi-cursor-sdk live runs.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
node scripts/steering-rpc-smoke.mjs
|
|
18
|
+
|
|
19
|
+
Environment:
|
|
20
|
+
SMOKE_SESSION_DIR Session directory for the RPC pi run. Defaults to /tmp/pi-cursor-steer-smoke-<timestamp>.
|
|
21
|
+
CURSOR_API_KEY Required Cursor API key for live pi runs.
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
-h, --help Show this help.
|
|
25
|
+
|
|
26
|
+
Exit codes:
|
|
27
|
+
0 steering scenario completed without AgentBusyError; STEER_OK and STEER_CHAIN present
|
|
28
|
+
1 validation failure, timeout, AgentBusyError, or non-zero pi exit
|
|
29
|
+
|
|
30
|
+
Notes:
|
|
31
|
+
- Runs pi in RPC mode with native tool replay enabled and the pi bridge disabled.
|
|
32
|
+
- Sends steer after the replayed bash tool finishes execution (post toolResult boundary).
|
|
33
|
+
- Prints a single JSON result line on success; errors go to stderr.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fail(message) {
|
|
37
|
+
throw new Error(message);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseEvents(stdout) {
|
|
41
|
+
const events = [];
|
|
42
|
+
for (const line of stdout.split("\n")) {
|
|
43
|
+
if (!line.trim()) continue;
|
|
44
|
+
try {
|
|
45
|
+
events.push(JSON.parse(line));
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore partial lines
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return events;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assistantText(events) {
|
|
54
|
+
return events
|
|
55
|
+
.filter((event) => event.type === "message_end" && event.message?.role === "assistant")
|
|
56
|
+
.map((event) =>
|
|
57
|
+
(event.message.content ?? [])
|
|
58
|
+
.filter((block) => block.type === "text")
|
|
59
|
+
.map((block) => block.text)
|
|
60
|
+
.join("\n"),
|
|
61
|
+
)
|
|
62
|
+
.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasToolUseTurn(events) {
|
|
66
|
+
return events.some(
|
|
67
|
+
(event) =>
|
|
68
|
+
event.type === "message_end" &&
|
|
69
|
+
event.message?.role === "assistant" &&
|
|
70
|
+
event.message?.stopReason === "toolUse",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hasToolExecutionEnd(events) {
|
|
75
|
+
return events.some((event) => event.type === "tool_execution_end");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function waitFor(getStdout, predicate, timeoutMs = 300_000) {
|
|
79
|
+
const start = Date.now();
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const tick = () => {
|
|
82
|
+
const events = parseEvents(getStdout());
|
|
83
|
+
if (predicate(events)) {
|
|
84
|
+
resolve(events);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (Date.now() - start > timeoutMs) {
|
|
88
|
+
reject(
|
|
89
|
+
new Error(
|
|
90
|
+
`timeout after ${timeoutMs}ms\nassistantText=${assistantText(events)}\nstdoutTail=${getStdout().slice(-4000)}`,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setTimeout(tick, 500);
|
|
96
|
+
};
|
|
97
|
+
tick();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function waitForChildClose(child) {
|
|
102
|
+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(child.exitCode ?? 1);
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function signalChild(child, signal) {
|
|
109
|
+
if (!child.pid) return;
|
|
110
|
+
try {
|
|
111
|
+
if (process.platform === "win32") {
|
|
112
|
+
child.kill(signal);
|
|
113
|
+
} else {
|
|
114
|
+
process.kill(-child.pid, signal);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
try {
|
|
118
|
+
child.kill(signal);
|
|
119
|
+
} catch {
|
|
120
|
+
// child already exited
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function terminateChild(child) {
|
|
126
|
+
child.stdin.destroy();
|
|
127
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
128
|
+
signalChild(child, "SIGTERM");
|
|
129
|
+
const killTimer = setTimeout(() => signalChild(child, "SIGKILL"), CHILD_SHUTDOWN_GRACE_MS);
|
|
130
|
+
try {
|
|
131
|
+
await waitForChildClose(child);
|
|
132
|
+
} finally {
|
|
133
|
+
clearTimeout(killTimer);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runPiRpcSmoke(sessionDir) {
|
|
138
|
+
const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2.5", "--mode", "rpc", "--session-dir", sessionDir];
|
|
139
|
+
const env = {
|
|
140
|
+
...process.env,
|
|
141
|
+
PI_CURSOR_SETTING_SOURCES: "none",
|
|
142
|
+
PI_CURSOR_NATIVE_TOOL_DISPLAY: "1",
|
|
143
|
+
PI_CURSOR_PI_TOOL_BRIDGE: "0",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const child = spawn("pi", args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
147
|
+
let closed = false;
|
|
148
|
+
let stdout = "";
|
|
149
|
+
let stderr = "";
|
|
150
|
+
child.stdout.on("data", (chunk) => {
|
|
151
|
+
stdout += chunk.toString();
|
|
152
|
+
});
|
|
153
|
+
child.stderr.on("data", (chunk) => {
|
|
154
|
+
stderr += chunk.toString();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const send = (obj) => {
|
|
158
|
+
if (!child.stdin.writable) fail("pi stdin closed before smoke command could be sent");
|
|
159
|
+
child.stdin.write(`${JSON.stringify(obj)}\n`);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
send({
|
|
164
|
+
type: "prompt",
|
|
165
|
+
message:
|
|
166
|
+
"Steering smoke. Use bash once to run: git status --short. Do not answer until after the tool completes. Final answer must include STEER_OK=yes.",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await waitFor(
|
|
170
|
+
() => stdout,
|
|
171
|
+
(events) => hasToolUseTurn(events),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await waitFor(
|
|
175
|
+
() => stdout,
|
|
176
|
+
(events) => hasToolExecutionEnd(events),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
send({ type: "steer", message: "and also include STEER_CHAIN=ok in the final answer" });
|
|
180
|
+
|
|
181
|
+
await waitFor(
|
|
182
|
+
() => stdout,
|
|
183
|
+
(events) => {
|
|
184
|
+
const text = assistantText(events);
|
|
185
|
+
return text.includes("STEER_OK=yes") && text.includes("STEER_CHAIN=ok") && events.some((event) => event.type === "agent_end");
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const combined = stdout + stderr;
|
|
190
|
+
if (/already has active run|AgentBusyError/i.test(combined)) {
|
|
191
|
+
fail("AgentBusyError detected in smoke output");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const text = assistantText(parseEvents(stdout));
|
|
195
|
+
if (!text.includes("STEER_OK=yes")) {
|
|
196
|
+
fail(`missing STEER_OK=yes in assistant output: ${text.slice(0, 500)}`);
|
|
197
|
+
}
|
|
198
|
+
if (!text.includes("STEER_CHAIN=ok")) {
|
|
199
|
+
fail(`missing STEER_CHAIN=ok in assistant output: ${text.slice(0, 500)}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
child.stdin.end();
|
|
203
|
+
const exitCode = await waitForChildClose(child);
|
|
204
|
+
closed = true;
|
|
205
|
+
if (exitCode !== 0) {
|
|
206
|
+
fail(`pi exited ${exitCode}\nstderr=${stderr.slice(-2000)}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
sessionDir,
|
|
212
|
+
steerOk: true,
|
|
213
|
+
steerChain: true,
|
|
214
|
+
};
|
|
215
|
+
} finally {
|
|
216
|
+
if (!closed) await terminateChild(child);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
222
|
+
printHelp();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!process.env.CURSOR_API_KEY) {
|
|
227
|
+
fail("steering-rpc-smoke: CURSOR_API_KEY is required");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const sessionDir = process.env.SMOKE_SESSION_DIR ?? join("/tmp", `pi-cursor-steer-smoke-${Date.now()}`);
|
|
231
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
232
|
+
console.log(JSON.stringify(await runPiRpcSmoke(sessionDir)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
main().catch((error) => {
|
|
236
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
});
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
5
|
+
SMOKE_DIR="${SMOKE_DIR:-/tmp/pi-cursor-sdk-live-smoke-$(date +%Y%m%dT%H%M%S)}"
|
|
6
|
+
SHELL_BIN="${SHELL:-/bin/bash}"
|
|
7
|
+
|
|
8
|
+
PI_BASE=(
|
|
9
|
+
pi -e "$ROOT"
|
|
10
|
+
--cursor-no-fast
|
|
11
|
+
--model cursor/composer-2.5
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
TMUX_SESSIONS=()
|
|
15
|
+
|
|
16
|
+
cleanup() {
|
|
17
|
+
local session
|
|
18
|
+
for session in "${TMUX_SESSIONS[@]:-}"; do
|
|
19
|
+
tmux kill-session -t "$session" 2>/dev/null || true
|
|
20
|
+
done
|
|
21
|
+
}
|
|
22
|
+
trap cleanup EXIT
|
|
23
|
+
|
|
24
|
+
print_help() {
|
|
25
|
+
cat <<EOF
|
|
26
|
+
Partial live smoke runner for pi-cursor-sdk (subset of docs/cursor-live-smoke-checklist.md).
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
./scripts/tmux-live-smoke.sh
|
|
30
|
+
SMOKE_DIR=/tmp/pi-cursor-smoke ./scripts/tmux-live-smoke.sh
|
|
31
|
+
|
|
32
|
+
Environment:
|
|
33
|
+
SMOKE_DIR Artifact directory. Defaults to /tmp/pi-cursor-sdk-live-smoke-<timestamp>.
|
|
34
|
+
CURSOR_API_KEY Required for live Cursor runs.
|
|
35
|
+
|
|
36
|
+
Prerequisites:
|
|
37
|
+
pi, node, rg, tmux on PATH
|
|
38
|
+
timeout or gtimeout optional; bash process-group kill fallback is used when absent
|
|
39
|
+
|
|
40
|
+
Coverage:
|
|
41
|
+
- prereq model listing
|
|
42
|
+
- basic non-interactive prompt (retry-empty-output; strict output assertion)
|
|
43
|
+
- default ambient settings prompt (strict; no retry)
|
|
44
|
+
- simple non-interactive math prompt (strict; no retry)
|
|
45
|
+
- interactive TUI math/footer polling with cleanup
|
|
46
|
+
- RPC steering after native replay tool execution (tmux-isolated)
|
|
47
|
+
- diagnostics safety scan
|
|
48
|
+
- JSONL assistant usage validation
|
|
49
|
+
|
|
50
|
+
Not covered here:
|
|
51
|
+
bridge MCP, standalone native replay, abort/cancel, packaging, full checklist sections 4-9
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
-h, --help Show this help.
|
|
55
|
+
|
|
56
|
+
Exit codes:
|
|
57
|
+
0 all partial checks passed
|
|
58
|
+
1 prerequisite, smoke, safety, or JSONL validation failure
|
|
59
|
+
EOF
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
log() {
|
|
63
|
+
printf '[smoke] %s\n' "$*"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fail() {
|
|
67
|
+
printf '[smoke] FAIL: %s\n' "$*" >&2
|
|
68
|
+
exit 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
require_cmd() {
|
|
72
|
+
command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
run_with_timeout() {
|
|
76
|
+
local timeout_secs="$1"
|
|
77
|
+
shift
|
|
78
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
79
|
+
timeout "$timeout_secs" "$@"
|
|
80
|
+
return $?
|
|
81
|
+
fi
|
|
82
|
+
if command -v gtimeout >/dev/null 2>&1; then
|
|
83
|
+
gtimeout "$timeout_secs" "$@"
|
|
84
|
+
return $?
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
local restore_monitor=0
|
|
88
|
+
case $- in
|
|
89
|
+
*m*) ;;
|
|
90
|
+
*)
|
|
91
|
+
restore_monitor=1
|
|
92
|
+
set -m
|
|
93
|
+
;;
|
|
94
|
+
esac
|
|
95
|
+
|
|
96
|
+
"$@" &
|
|
97
|
+
local pid=$!
|
|
98
|
+
(
|
|
99
|
+
sleep "$timeout_secs"
|
|
100
|
+
kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
|
|
101
|
+
sleep 2
|
|
102
|
+
kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true
|
|
103
|
+
) &
|
|
104
|
+
local watcher=$!
|
|
105
|
+
local code=0
|
|
106
|
+
if wait "$pid"; then
|
|
107
|
+
code=0
|
|
108
|
+
else
|
|
109
|
+
code=$?
|
|
110
|
+
fi
|
|
111
|
+
kill "$watcher" 2>/dev/null || true
|
|
112
|
+
wait "$watcher" 2>/dev/null || true
|
|
113
|
+
if (( restore_monitor )); then
|
|
114
|
+
set +m
|
|
115
|
+
fi
|
|
116
|
+
return "$code"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
tail_file() {
|
|
120
|
+
local file="$1"
|
|
121
|
+
local lines="${2:-80}"
|
|
122
|
+
if [[ -s "$file" ]]; then
|
|
123
|
+
tail -n "$lines" "$file" || true
|
|
124
|
+
else
|
|
125
|
+
printf '<empty: %s>\n' "$file"
|
|
126
|
+
fi
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
assert_file_contains() {
|
|
130
|
+
local name="$1"
|
|
131
|
+
local file="$2"
|
|
132
|
+
local pattern="$3"
|
|
133
|
+
local label="$4"
|
|
134
|
+
if ! rg -q "$pattern" "$file"; then
|
|
135
|
+
printf '[smoke] %s missing %s in %s\n' "$name" "$label" "$file" >&2
|
|
136
|
+
printf '[smoke] %s transcript tail:\n' "$name" >&2
|
|
137
|
+
tail_file "$file" 120 >&2
|
|
138
|
+
fail "$name missing ${label}"
|
|
139
|
+
fi
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
is_empty_retryable_exit() {
|
|
143
|
+
local code="$1"
|
|
144
|
+
local stdout="$2"
|
|
145
|
+
[[ ! -s "$stdout" && ( "$code" == "0" || "$code" == "124" || "$code" == "137" || "$code" == "143" ) ]]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
run_direct_attempt() {
|
|
149
|
+
local name="$1"
|
|
150
|
+
local timeout_secs="$2"
|
|
151
|
+
local stdout="$3"
|
|
152
|
+
local stderr="$4"
|
|
153
|
+
shift 4
|
|
154
|
+
rm -f "$stdout" "$stderr"
|
|
155
|
+
|
|
156
|
+
if run_with_timeout "$timeout_secs" "$@" >"$stdout" 2>"$stderr"; then
|
|
157
|
+
return 0
|
|
158
|
+
fi
|
|
159
|
+
return $?
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
run_direct_fail() {
|
|
163
|
+
local name="$1"
|
|
164
|
+
local code="$2"
|
|
165
|
+
local stdout="$3"
|
|
166
|
+
local stderr="$4"
|
|
167
|
+
local label="$5"
|
|
168
|
+
if [[ "$code" != "0" ]]; then
|
|
169
|
+
cat "$stderr" >&2 || true
|
|
170
|
+
fail "$name exited $code"
|
|
171
|
+
fi
|
|
172
|
+
printf '[smoke] %s missing %s in %s\n' "$name" "$label" "$stdout" >&2
|
|
173
|
+
printf '[smoke] %s stdout tail:\n' "$name" >&2
|
|
174
|
+
tail_file "$stdout" 120 >&2
|
|
175
|
+
printf '[smoke] %s stderr tail:\n' "$name" >&2
|
|
176
|
+
tail_file "$stderr" 80 >&2
|
|
177
|
+
fail "$name missing ${label}"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
run_direct() {
|
|
181
|
+
local name="$1"
|
|
182
|
+
local timeout_secs="$2"
|
|
183
|
+
local policy="$3"
|
|
184
|
+
local expected_pattern="$4"
|
|
185
|
+
local expected_label="$5"
|
|
186
|
+
shift 5
|
|
187
|
+
local stdout="$SMOKE_DIR/${name}.stdout.txt"
|
|
188
|
+
local stderr="$SMOKE_DIR/${name}.stderr.txt"
|
|
189
|
+
local code=0
|
|
190
|
+
|
|
191
|
+
if run_direct_attempt "$name" "$timeout_secs" "$stdout" "$stderr" "$@"; then
|
|
192
|
+
code=0
|
|
193
|
+
else
|
|
194
|
+
code=$?
|
|
195
|
+
fi
|
|
196
|
+
if [[ "$code" == "0" ]] && rg -q "$expected_pattern" "$stdout"; then
|
|
197
|
+
log "$name PASS"
|
|
198
|
+
return 0
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
case "$policy" in
|
|
202
|
+
strict)
|
|
203
|
+
run_direct_fail "$name" "$code" "$stdout" "$stderr" "$expected_label"
|
|
204
|
+
;;
|
|
205
|
+
retry-empty-output)
|
|
206
|
+
local first_stdout="$SMOKE_DIR/${name}.attempt1.stdout.txt"
|
|
207
|
+
local first_stderr="$SMOKE_DIR/${name}.attempt1.stderr.txt"
|
|
208
|
+
if ! is_empty_retryable_exit "$code" "$stdout"; then
|
|
209
|
+
run_direct_fail "$name" "$code" "$stdout" "$stderr" "$expected_label"
|
|
210
|
+
fi
|
|
211
|
+
mv "$stdout" "$first_stdout" 2>/dev/null || true
|
|
212
|
+
mv "$stderr" "$first_stderr" 2>/dev/null || true
|
|
213
|
+
log "$name retrying once after empty output with exit $code"
|
|
214
|
+
if run_direct_attempt "$name" "$timeout_secs" "$stdout" "$stderr" "$@"; then
|
|
215
|
+
local retry_code=0
|
|
216
|
+
if rg -q "$expected_pattern" "$stdout"; then
|
|
217
|
+
log "$name PASS after retry (first exit $code; first stderr: $first_stderr)"
|
|
218
|
+
return 0
|
|
219
|
+
fi
|
|
220
|
+
printf '[smoke] %s retry exited %s but still missed %s\n' "$name" "$retry_code" "$expected_label" >&2
|
|
221
|
+
else
|
|
222
|
+
local retry_code=$?
|
|
223
|
+
printf '[smoke] %s retry exited %s after first empty output exit %s\n' "$name" "$retry_code" "$code" >&2
|
|
224
|
+
fi
|
|
225
|
+
printf '[smoke] %s first stdout tail:\n' "$name" >&2
|
|
226
|
+
tail_file "$first_stdout" 80 >&2
|
|
227
|
+
printf '[smoke] %s first stderr tail:\n' "$name" >&2
|
|
228
|
+
tail_file "$first_stderr" 80 >&2
|
|
229
|
+
printf '[smoke] %s retry stdout tail:\n' "$name" >&2
|
|
230
|
+
tail_file "$stdout" 120 >&2
|
|
231
|
+
printf '[smoke] %s retry stderr tail:\n' "$name" >&2
|
|
232
|
+
tail_file "$stderr" 80 >&2
|
|
233
|
+
fail "$name retry failed after empty output"
|
|
234
|
+
;;
|
|
235
|
+
*)
|
|
236
|
+
fail "$name unknown run_direct policy: $policy (expected strict or retry-empty-output)"
|
|
237
|
+
;;
|
|
238
|
+
esac
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
quote_command() {
|
|
242
|
+
local quoted=()
|
|
243
|
+
local arg
|
|
244
|
+
for arg in "$@"; do
|
|
245
|
+
printf -v arg '%q' "$arg"
|
|
246
|
+
quoted+=("$arg")
|
|
247
|
+
done
|
|
248
|
+
printf '%s ' "${quoted[@]}"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
run_tui_math_footer_poll() {
|
|
252
|
+
local name="$1"
|
|
253
|
+
local timeout_secs="$2"
|
|
254
|
+
shift 2
|
|
255
|
+
local session="pi-cursor-smoke-${name}-$$"
|
|
256
|
+
local capture="$SMOKE_DIR/${name}.capture.txt"
|
|
257
|
+
local script
|
|
258
|
+
local command
|
|
259
|
+
command="$(quote_command "$@")"
|
|
260
|
+
rm -f "$capture"
|
|
261
|
+
|
|
262
|
+
printf -v script 'cd %q || exit 97
|
|
263
|
+
exec %s
|
|
264
|
+
' "$ROOT" "$command"
|
|
265
|
+
tmux new-session -d -s "$session" -x 120 -y 40 -- "$SHELL_BIN" -lc "$script"
|
|
266
|
+
TMUX_SESSIONS+=("$session")
|
|
267
|
+
|
|
268
|
+
local elapsed=0
|
|
269
|
+
local missing=""
|
|
270
|
+
while true; do
|
|
271
|
+
tmux capture-pane -pt "$session" >"$capture" 2>/dev/null || true
|
|
272
|
+
missing=""
|
|
273
|
+
rg -q "SUM=42" "$capture" || missing="${missing} SUM=42"
|
|
274
|
+
rg -q "\\(cursor\\) composer-2\\.5" "$capture" || missing="${missing} footer (cursor) composer-2.5"
|
|
275
|
+
if [[ -z "$missing" ]]; then
|
|
276
|
+
tmux kill-session -t "$session" 2>/dev/null || true
|
|
277
|
+
log "$name PASS"
|
|
278
|
+
return 0
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
sleep 2
|
|
282
|
+
elapsed=$((elapsed + 2))
|
|
283
|
+
if (( elapsed >= timeout_secs )); then
|
|
284
|
+
tmux kill-session -t "$session" 2>/dev/null || true
|
|
285
|
+
printf '[smoke] %s timed out after %ss; missing:%s\n' "$name" "$timeout_secs" "$missing" >&2
|
|
286
|
+
printf '[smoke] %s capture tail:\n' "$name" >&2
|
|
287
|
+
tail_file "$capture" 120 >&2
|
|
288
|
+
fail "$name timed out waiting for TUI evidence"
|
|
289
|
+
fi
|
|
290
|
+
done
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
run_tmux() {
|
|
294
|
+
local name="$1"
|
|
295
|
+
local timeout_secs="$2"
|
|
296
|
+
local dump_stderr_on_fail="$3"
|
|
297
|
+
shift 3
|
|
298
|
+
local session="pi-cursor-smoke-${name}-$$"
|
|
299
|
+
local marker="$SMOKE_DIR/${name}.done"
|
|
300
|
+
local stdout="$SMOKE_DIR/${name}.stdout.txt"
|
|
301
|
+
local stderr="$SMOKE_DIR/${name}.stderr.txt"
|
|
302
|
+
local command
|
|
303
|
+
local script
|
|
304
|
+
command="$(quote_command "$@")"
|
|
305
|
+
rm -f "$marker" "$stdout" "$stderr"
|
|
306
|
+
|
|
307
|
+
printf -v script 'cd %q || exit 97
|
|
308
|
+
%s> %q 2> %q
|
|
309
|
+
code=$?
|
|
310
|
+
printf '\''%%s\n'\'' "$code" > %q
|
|
311
|
+
' "$ROOT" "$command" "$stdout" "$stderr" "$marker"
|
|
312
|
+
tmux new-session -d -s "$session" -- "$SHELL_BIN" -lc "$script"
|
|
313
|
+
TMUX_SESSIONS+=("$session")
|
|
314
|
+
|
|
315
|
+
local elapsed=0
|
|
316
|
+
while [[ ! -f "$marker" ]]; do
|
|
317
|
+
sleep 2
|
|
318
|
+
elapsed=$((elapsed + 2))
|
|
319
|
+
if (( elapsed >= timeout_secs )); then
|
|
320
|
+
tmux capture-pane -pt "$session" >"$SMOKE_DIR/${name}.capture.txt" || true
|
|
321
|
+
tmux kill-session -t "$session" 2>/dev/null || true
|
|
322
|
+
fail "$name timed out after ${timeout_secs}s (see ${name}.capture.txt)"
|
|
323
|
+
fi
|
|
324
|
+
done
|
|
325
|
+
|
|
326
|
+
local code
|
|
327
|
+
code="$(cat "$marker")"
|
|
328
|
+
tmux kill-session -t "$session" 2>/dev/null || true
|
|
329
|
+
if [[ "$code" != "0" ]]; then
|
|
330
|
+
if [[ "$dump_stderr_on_fail" == "1" ]]; then
|
|
331
|
+
cat "$stderr" >&2 || true
|
|
332
|
+
fi
|
|
333
|
+
fail "$name exited $code"
|
|
334
|
+
fi
|
|
335
|
+
log "$name PASS"
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
model_listed() {
|
|
339
|
+
local file="$1"
|
|
340
|
+
rg -q "composer-2\\.5" "$file"
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
344
|
+
print_help
|
|
345
|
+
exit 0
|
|
346
|
+
fi
|
|
347
|
+
|
|
348
|
+
require_cmd pi
|
|
349
|
+
require_cmd node
|
|
350
|
+
require_cmd rg
|
|
351
|
+
require_cmd tmux
|
|
352
|
+
|
|
353
|
+
if [[ -z "${CURSOR_API_KEY:-}" ]]; then
|
|
354
|
+
fail "CURSOR_API_KEY is required"
|
|
355
|
+
fi
|
|
356
|
+
|
|
357
|
+
mkdir -p "$SMOKE_DIR"
|
|
358
|
+
printf '%s\n' "$SMOKE_DIR" >"$SMOKE_DIR/smoke-dir.txt"
|
|
359
|
+
|
|
360
|
+
log "SMOKE_DIR=$SMOKE_DIR"
|
|
361
|
+
log "partial live smoke: prereq, basic, default-settings, noninteractive-math, tui, steering, diagnostics, jsonl"
|
|
362
|
+
|
|
363
|
+
if ! PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" --list-models cursor 2>"$SMOKE_DIR/prereq.stderr.txt" | tee "$SMOKE_DIR/prereq.models.txt" | rg -q "composer-2\\.5"; then
|
|
364
|
+
if ! model_listed "$SMOKE_DIR/prereq.stderr.txt"; then
|
|
365
|
+
fail "cursor/composer-2.5 not listed"
|
|
366
|
+
fi
|
|
367
|
+
fi
|
|
368
|
+
log "prereq PASS"
|
|
369
|
+
|
|
370
|
+
run_direct basic 600 retry-empty-output "PI_CURSOR_SMOKE_OK" "PI_CURSOR_SMOKE_OK" \
|
|
371
|
+
env PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" \
|
|
372
|
+
--session-dir "$SMOKE_DIR/basic" \
|
|
373
|
+
--no-tools \
|
|
374
|
+
-p 'Live smoke. Reply exactly: PI_CURSOR_SMOKE_OK'
|
|
375
|
+
|
|
376
|
+
run_direct default-settings 300 strict "PRODUCT=42" "PRODUCT=42" \
|
|
377
|
+
"${PI_BASE[@]}" \
|
|
378
|
+
--session-dir "$SMOKE_DIR/default-settings" \
|
|
379
|
+
--no-tools \
|
|
380
|
+
-p 'Default settings smoke. Include PRODUCT=42 in the final answer.'
|
|
381
|
+
|
|
382
|
+
run_direct noninteractive-math 300 strict "SUM=42" "SUM=42" \
|
|
383
|
+
env PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" \
|
|
384
|
+
--session-dir "$SMOKE_DIR/noninteractive-math" \
|
|
385
|
+
--no-tools \
|
|
386
|
+
-p 'Noninteractive math smoke. Compute 19 + 23. Reply only with SUM=42.'
|
|
387
|
+
|
|
388
|
+
run_tui_math_footer_poll tui 420 \
|
|
389
|
+
env PI_CURSOR_SETTING_SOURCES=none "${PI_BASE[@]}" \
|
|
390
|
+
--session-dir "$SMOKE_DIR/tui" \
|
|
391
|
+
--no-tools \
|
|
392
|
+
'TUI smoke. Compute 19 + 23. Reply only with SUM=<number>.'
|
|
393
|
+
|
|
394
|
+
run_tmux steering 420 1 \
|
|
395
|
+
env "SMOKE_SESSION_DIR=$SMOKE_DIR/steering" node "$ROOT/scripts/steering-rpc-smoke.mjs"
|
|
396
|
+
rg -q '"steerOk":true' "$SMOKE_DIR/steering.stdout.txt" || fail "steering missing steerOk"
|
|
397
|
+
rg -q '"steerChain":true' "$SMOKE_DIR/steering.stdout.txt" || fail "steering missing steerChain"
|
|
398
|
+
rg -q "already has active run|AgentBusyError" "$SMOKE_DIR/steering.stdout.txt" "$SMOKE_DIR/steering.stderr.txt" && fail "steering hit AgentBusyError" || true
|
|
399
|
+
|
|
400
|
+
forbidden_files="$(find "$SMOKE_DIR" -type f \( -name '*stderr.txt' -o -name '*capture*.txt' \) -print0 |
|
|
401
|
+
xargs -0 grep -IlE 'CURSOR_API_KEY|Bearer [A-Za-z0-9._-]+|/cursor-pi-tool-bridge/[^ ]+/mcp|127\.0\.0\.1:[0-9]+/cursor-pi-tool-bridge|apiKey|cookie|session-cookie|secret-token' || true)"
|
|
402
|
+
if [[ -n "$forbidden_files" ]]; then
|
|
403
|
+
printf '[smoke] diagnostics safety scan found forbidden material in:\n' >&2
|
|
404
|
+
while IFS= read -r file; do
|
|
405
|
+
[[ -z "$file" ]] && continue
|
|
406
|
+
if [[ "$file" == "$SMOKE_DIR/"* ]]; then
|
|
407
|
+
printf '[smoke] %s\n' "${file#"$SMOKE_DIR/"}" >&2
|
|
408
|
+
else
|
|
409
|
+
printf '[smoke] %s\n' "$file" >&2
|
|
410
|
+
fi
|
|
411
|
+
done <<<"$forbidden_files"
|
|
412
|
+
fail "diagnostics safety scan found forbidden material"
|
|
413
|
+
fi
|
|
414
|
+
log "diagnostics safety PASS"
|
|
415
|
+
|
|
416
|
+
node "$ROOT/scripts/validate-smoke-jsonl.mjs" "$SMOKE_DIR"
|
|
417
|
+
log "jsonl structural scan PASS"
|
|
418
|
+
log "partial live smoke checks passed (see --help for uncovered checklist sections)"
|