pi-cursor-sdk 0.1.16 → 0.1.18
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 +53 -1
- package/README.md +2 -2
- package/docs/cursor-live-smoke-checklist.md +54 -41
- package/docs/cursor-model-ux-spec.md +4 -3
- package/docs/cursor-testing-lessons.md +199 -0
- package/package.json +14 -5
- package/scripts/isolated-cursor-smoke.sh +226 -0
- package/scripts/steering-rpc-smoke.mjs +238 -0
- package/scripts/tmux-live-smoke.sh +418 -0
- package/scripts/validate-smoke-jsonl.mjs +207 -0
- package/src/cursor-context-tools.ts +6 -0
- package/src/cursor-display-text.ts +10 -0
- package/src/cursor-edit-diff.ts +11 -0
- package/src/cursor-env-boolean.ts +22 -0
- package/src/cursor-live-run-coordinator.ts +483 -0
- package/src/cursor-native-replay-routing.ts +48 -0
- package/src/cursor-native-replay-trace.ts +29 -0
- package/src/cursor-native-tool-display-registration.ts +103 -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 -648
- 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 +42 -1104
- package/src/cursor-provider-live-run-drain.ts +405 -0
- package/src/cursor-provider-turn-coordinator.ts +460 -0
- package/src/cursor-provider.ts +77 -1103
- package/src/cursor-question-tool.ts +9 -1
- 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-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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Isolated /tmp install + fail-fast live smoke for pi-cursor-sdk native replay.
|
|
3
|
+
#
|
|
4
|
+
# Validates packed extension load, plan-strip resync, and absence of "Tool * not found".
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
8
|
+
REAL_HOME="${REAL_HOME:-$HOME}"
|
|
9
|
+
PI_AGENT_DIR="${PI_AGENT_DIR:-$REAL_HOME/.pi/agent}"
|
|
10
|
+
AUTH_JSON="${AUTH_JSON:-$PI_AGENT_DIR/auth.json}"
|
|
11
|
+
REPO="${REPO:-$ROOT}"
|
|
12
|
+
ISOLATED="${ISOLATED:-/tmp/pi-cursor-sdk-isolated-$(date +%Y%m%dT%H%M%S)}"
|
|
13
|
+
PI_LIVE_TIMEOUT="${PI_LIVE_TIMEOUT:-45}"
|
|
14
|
+
SKIP_LIVE="${SKIP_LIVE:-0}"
|
|
15
|
+
SKIP_UNIT="${SKIP_UNIT:-0}"
|
|
16
|
+
PI_BIN="${PI_BIN:-pi}"
|
|
17
|
+
PI_PATH="${PI_PATH:-/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin}"
|
|
18
|
+
|
|
19
|
+
PACK_DIR="$ISOLATED/pack"
|
|
20
|
+
EXTRACT_DIR="$ISOLATED/extract"
|
|
21
|
+
PROJECT_DIR="$ISOLATED/project"
|
|
22
|
+
SESSION_ROOT="$ISOLATED/sessions"
|
|
23
|
+
SHIM_DIR="$ROOT/scripts/fixtures/plan-strip-shim"
|
|
24
|
+
HOME_DIR="$ISOLATED/home"
|
|
25
|
+
|
|
26
|
+
print_help() {
|
|
27
|
+
cat <<EOF
|
|
28
|
+
Isolated /tmp install smoke for pi-cursor-sdk (native replay + plan-strip resync).
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
./scripts/isolated-cursor-smoke.sh
|
|
32
|
+
SKIP_LIVE=1 ./scripts/isolated-cursor-smoke.sh
|
|
33
|
+
PI_LIVE_TIMEOUT=90 ./scripts/isolated-cursor-smoke.sh
|
|
34
|
+
|
|
35
|
+
Environment:
|
|
36
|
+
REPO Repo under test (default: script parent directory).
|
|
37
|
+
ISOLATED Artifact root (default: /tmp/pi-cursor-sdk-isolated-<timestamp>).
|
|
38
|
+
REAL_HOME Source for auth.json (default: \$HOME).
|
|
39
|
+
AUTH_JSON Path to pi auth.json to seed isolated HOME (default: ~/.pi/agent/auth.json).
|
|
40
|
+
PI_LIVE_TIMEOUT Per live pi check timeout in seconds (default: 45).
|
|
41
|
+
PI_BIN pi executable (default: pi on PATH).
|
|
42
|
+
PI_PATH PATH for isolated pi runs.
|
|
43
|
+
SKIP_LIVE=1 Run unit tests + pack only; skip live Cursor calls.
|
|
44
|
+
SKIP_UNIT=1 Skip repo unit tests (live checks only).
|
|
45
|
+
CURSOR_API_KEY Optional fallback when auth.json lacks cursor provider.
|
|
46
|
+
|
|
47
|
+
Prerequisites:
|
|
48
|
+
node, npm, pi, rg, python3 on PATH
|
|
49
|
+
~/.pi/agent/auth.json with cursor provider OR CURSOR_API_KEY
|
|
50
|
+
|
|
51
|
+
Exit codes:
|
|
52
|
+
0 all requested checks passed
|
|
53
|
+
1 prerequisite, unit, pack, live smoke, or JSONL replay validation failure
|
|
54
|
+
EOF
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log() {
|
|
58
|
+
printf '[isolated-smoke] %s\n' "$*"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fail() {
|
|
62
|
+
printf '[isolated-smoke] FAIL: %s\n' "$*" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
seed_pi_agent_home() {
|
|
67
|
+
local home="$1"
|
|
68
|
+
mkdir -p "$home/.pi/agent"
|
|
69
|
+
if [[ -f "$AUTH_JSON" ]]; then
|
|
70
|
+
cp "$AUTH_JSON" "$home/.pi/agent/auth.json"
|
|
71
|
+
chmod 600 "$home/.pi/agent/auth.json"
|
|
72
|
+
log "seeded $home/.pi/agent/auth.json"
|
|
73
|
+
else
|
|
74
|
+
log "WARN: no auth.json at $AUTH_JSON"
|
|
75
|
+
fi
|
|
76
|
+
if [[ -f "$PI_AGENT_DIR/models.json" ]]; then
|
|
77
|
+
cp "$PI_AGENT_DIR/models.json" "$home/.pi/agent/models.json"
|
|
78
|
+
fi
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
has_auth_provider() {
|
|
82
|
+
local provider="$1"
|
|
83
|
+
python3 - "$provider" "$HOME_DIR/.pi/agent/auth.json" <<'PY'
|
|
84
|
+
import json, sys
|
|
85
|
+
provider, path = sys.argv[1], sys.argv[2]
|
|
86
|
+
try:
|
|
87
|
+
data = json.load(open(path))
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
sys.exit(0 if provider in data and data[provider] else 1)
|
|
91
|
+
PY
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
run_with_timeout() {
|
|
95
|
+
local label="$1"
|
|
96
|
+
local seconds="$2"
|
|
97
|
+
shift 2
|
|
98
|
+
log "$label (timeout ${seconds}s)"
|
|
99
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
100
|
+
timeout --foreground "${seconds}s" "$@" || {
|
|
101
|
+
local rc=$?
|
|
102
|
+
[[ $rc -eq 124 ]] && fail "$label timed out after ${seconds}s"
|
|
103
|
+
fail "$label exited $rc"
|
|
104
|
+
}
|
|
105
|
+
return
|
|
106
|
+
fi
|
|
107
|
+
if command -v gtimeout >/dev/null 2>&1; then
|
|
108
|
+
gtimeout "${seconds}s" "$@" || {
|
|
109
|
+
local rc=$?
|
|
110
|
+
[[ $rc -eq 124 ]] && fail "$label timed out after ${seconds}s"
|
|
111
|
+
fail "$label exited $rc"
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
fi
|
|
115
|
+
"$@" &
|
|
116
|
+
local pid=$!
|
|
117
|
+
local waited=0
|
|
118
|
+
while kill -0 "$pid" 2>/dev/null; do
|
|
119
|
+
if (( waited >= seconds )); then
|
|
120
|
+
kill -TERM "$pid" 2>/dev/null || true
|
|
121
|
+
sleep 1
|
|
122
|
+
kill -KILL "$pid" 2>/dev/null || true
|
|
123
|
+
fail "$label timed out after ${seconds}s"
|
|
124
|
+
fi
|
|
125
|
+
sleep 1
|
|
126
|
+
waited=$((waited + 1))
|
|
127
|
+
done
|
|
128
|
+
wait "$pid" || fail "$label exited $?"
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
validate_replay_jsonl() {
|
|
132
|
+
local dir="$1"
|
|
133
|
+
node "$ROOT/scripts/validate-smoke-jsonl.mjs" --replay-errors-only "$dir"
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
137
|
+
print_help
|
|
138
|
+
exit 0
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
if [[ -f "${SECRETS_FILE:-$REAL_HOME/.secrets}" ]]; then
|
|
142
|
+
set +u
|
|
143
|
+
# shellcheck disable=SC1090
|
|
144
|
+
source "${SECRETS_FILE:-$REAL_HOME/.secrets}"
|
|
145
|
+
set -u
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
command -v node >/dev/null || fail "missing node"
|
|
149
|
+
command -v npm >/dev/null || fail "missing npm"
|
|
150
|
+
command -v rg >/dev/null || fail "missing rg"
|
|
151
|
+
command -v python3 >/dev/null || fail "missing python3"
|
|
152
|
+
|
|
153
|
+
mkdir -p "$PACK_DIR" "$EXTRACT_DIR" "$PROJECT_DIR" "$SESSION_ROOT" "$HOME_DIR"
|
|
154
|
+
seed_pi_agent_home "$HOME_DIR"
|
|
155
|
+
|
|
156
|
+
log "isolated root: $ISOLATED"
|
|
157
|
+
log "HOME=$HOME_DIR"
|
|
158
|
+
|
|
159
|
+
if [[ "$SKIP_UNIT" != "1" ]]; then
|
|
160
|
+
log "preflight: repo unit tests"
|
|
161
|
+
run_with_timeout "npm test" 120 bash -lc "cd '$REPO' && npm test"
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
if [[ "$SKIP_LIVE" == "1" ]]; then
|
|
165
|
+
log "SKIP_LIVE=1 — skipping live pi checks"
|
|
166
|
+
exit 0
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
if ! has_auth_provider cursor && [[ -z "${CURSOR_API_KEY:-}" ]]; then
|
|
170
|
+
fail "no cursor auth in $HOME_DIR/.pi/agent/auth.json and CURSOR_API_KEY unset"
|
|
171
|
+
fi
|
|
172
|
+
|
|
173
|
+
command -v "$PI_BIN" >/dev/null || fail "PI_BIN not found: $PI_BIN"
|
|
174
|
+
|
|
175
|
+
log "npm pack from $REPO"
|
|
176
|
+
(cd "$REPO" && npm pack --pack-destination "$PACK_DIR" >/dev/null 2>&1)
|
|
177
|
+
PACK_TGZ="$(ls -t "$PACK_DIR"/*.tgz | head -1)"
|
|
178
|
+
[[ -f "$PACK_TGZ" ]] || fail "missing pack tarball"
|
|
179
|
+
tar -xzf "$PACK_TGZ" -C "$EXTRACT_DIR"
|
|
180
|
+
[[ -d "$EXTRACT_DIR/package" ]] || fail "extract missing package/ dir"
|
|
181
|
+
|
|
182
|
+
log "npm install packed extension deps"
|
|
183
|
+
run_with_timeout "npm install --omit=dev" 120 bash -lc "cd '$EXTRACT_DIR/package' && npm install --omit=dev >/dev/null 2>&1"
|
|
184
|
+
|
|
185
|
+
log "pi install -l (clean HOME)"
|
|
186
|
+
cp "$REPO/README.md" "$PROJECT_DIR/README.md"
|
|
187
|
+
run_with_timeout "pi install" 30 env -i HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 \
|
|
188
|
+
bash -c "cd '$PROJECT_DIR' && '$PI_BIN' install -l '$EXTRACT_DIR/package' >/dev/null"
|
|
189
|
+
|
|
190
|
+
run_with_timeout "pi list" 15 env -i HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 \
|
|
191
|
+
bash -c "cd '$PROJECT_DIR' && '$PI_BIN' list" | rg -q "extract/package" || fail "packed extension not installed"
|
|
192
|
+
|
|
193
|
+
PI_ENV=(HOME="$HOME_DIR" PATH="$PI_PATH" MISE_DISABLE=1 PI_CURSOR_SETTING_SOURCES=none)
|
|
194
|
+
if [[ -n "${CURSOR_API_KEY:-}" ]]; then
|
|
195
|
+
PI_ENV+=(CURSOR_API_KEY="$CURSOR_API_KEY")
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
log "check: list-models"
|
|
199
|
+
LIST_OUT="$ISOLATED/list-models.txt"
|
|
200
|
+
run_with_timeout "list-models" 30 env -i "${PI_ENV[@]}" \
|
|
201
|
+
bash -c "cd '$PROJECT_DIR' && '$PI_BIN' --cursor-no-fast --list-models cursor > '$LIST_OUT' 2>&1"
|
|
202
|
+
rg -q "composer-2\\.5|composer-2-5" "$LIST_OUT" || fail "composer-2.5 not listed (see $LIST_OUT)"
|
|
203
|
+
|
|
204
|
+
log "check: basic provider prompt"
|
|
205
|
+
BASIC_DIR="$SESSION_ROOT/basic"
|
|
206
|
+
mkdir -p "$BASIC_DIR"
|
|
207
|
+
run_with_timeout "basic prompt" "$PI_LIVE_TIMEOUT" env -i "${PI_ENV[@]}" \
|
|
208
|
+
bash -c "cd '$PROJECT_DIR' && '$PI_BIN' --cursor-no-fast --model cursor/composer-2.5 --session-dir '$BASIC_DIR' --no-tools -p 'Reply exactly: PI_CURSOR_ISOLATED_OK' > '$ISOLATED/basic.stdout.txt' 2> '$ISOLATED/basic.stderr.txt'"
|
|
209
|
+
rg -q "PI_CURSOR_ISOLATED_OK" "$ISOLATED/basic.stdout.txt" || fail "basic prompt missing PI_CURSOR_ISOLATED_OK"
|
|
210
|
+
validate_replay_jsonl "$BASIC_DIR"
|
|
211
|
+
|
|
212
|
+
log "check: native replay"
|
|
213
|
+
REPLAY_DIR="$SESSION_ROOT/native-replay"
|
|
214
|
+
mkdir -p "$REPLAY_DIR"
|
|
215
|
+
run_with_timeout "native replay" "$PI_LIVE_TIMEOUT" env -i "${PI_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
|
|
216
|
+
bash -c "cd '$PROJECT_DIR' && '$PI_BIN' --cursor-no-fast --model cursor/composer-2.5 --session-dir '$REPLAY_DIR' -p 'Read ./README.md briefly, then answer README_SEEN=yes if it mentions pi-cursor-sdk.' > '$ISOLATED/replay.stdout.txt' 2> '$ISOLATED/replay.stderr.txt'"
|
|
217
|
+
validate_replay_jsonl "$REPLAY_DIR"
|
|
218
|
+
|
|
219
|
+
log "check: plan-strip shim (plan-mode execute reset)"
|
|
220
|
+
PLAN_DIR="$SESSION_ROOT/plan-strip"
|
|
221
|
+
mkdir -p "$PLAN_DIR"
|
|
222
|
+
run_with_timeout "plan-strip replay" "$PI_LIVE_TIMEOUT" env -i "${PI_ENV[@]}" PI_CURSOR_NATIVE_TOOL_DISPLAY=1 \
|
|
223
|
+
bash -c "cd '$PROJECT_DIR' && '$PI_BIN' -e '$SHIM_DIR' --cursor-no-fast --model cursor/composer-2.5 --session-dir '$PLAN_DIR' -p 'After reset, read README.md and answer PLAN_STRIP_OK=yes.' > '$ISOLATED/plan.stdout.txt' 2> '$ISOLATED/plan.stderr.txt'"
|
|
224
|
+
validate_replay_jsonl "$PLAN_DIR"
|
|
225
|
+
|
|
226
|
+
log "PASS isolated install smoke: $ISOLATED"
|
|
@@ -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
|
+
});
|