|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; |
| 4 | +import { join } from "node:path"; |
| 5 | +import { createChecks, evidenceDir, guardRealAuth, installCleanupHooks, makeSandbox } from "./lib/common.mjs"; |
| 6 | +import { startFakeModelServer } from "./lib/fake-model-server.mjs"; |
| 7 | +import { API_PRESETS, hermeticEnv, writeMockModelsJson } from "./lib/mock-loop-support.mjs"; |
| 8 | +import { RpcQaClient } from "./lib/rpc-qa-client.mjs"; |
| 9 | + |
| 10 | +const PROVIDER_ERROR_REASON = "provider error ended the turn (retries exhausted)"; |
| 11 | +const INTENTIONAL_REASON = "waiting on an explicit user decision"; |
| 12 | +const TURNS = [ |
| 13 | + { toolCalls: [{ id: "qa-create-goal", name: "create_goal", args: { objective: "Survive a provider outage" } }] }, |
| 14 | + { error: { status: 400, message: "SENPI_QA_TERMINAL_PROVIDER_ERROR" } }, |
| 15 | + { toolCalls: [{ id: "qa-get-reactivated-goal", name: "get_goal", args: {} }] }, |
| 16 | + { |
| 17 | + toolCalls: [ |
| 18 | + { |
| 19 | + id: "qa-intentional-block", |
| 20 | + name: "update_goal", |
| 21 | + args: { status: "blocked", reason: INTENTIONAL_REASON }, |
| 22 | + }, |
| 23 | + ], |
| 24 | + }, |
| 25 | + { text: "SENPI-QA-INTENTIONAL-BLOCK-SET" }, |
| 26 | + { toolCalls: [{ id: "qa-get-still-blocked-goal", name: "get_goal", args: {} }] }, |
| 27 | + { text: "SENPI-QA-INTENTIONAL-BLOCK-PRESERVED" }, |
| 28 | +]; |
| 29 | + |
| 30 | +function flag(name) { |
| 31 | + const index = process.argv.indexOf(name); |
| 32 | + return index >= 0 ? process.argv[index + 1] : undefined; |
| 33 | +} |
| 34 | + |
| 35 | +function findJsonFiles(root) { |
| 36 | + if (!existsSync(root)) return []; |
| 37 | + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { |
| 38 | + const path = join(root, entry.name); |
| 39 | + if (entry.isDirectory()) return findJsonFiles(path); |
| 40 | + return entry.isFile() && entry.name.endsWith(".json") ? [path] : []; |
| 41 | + }); |
| 42 | +} |
| 43 | + |
| 44 | +function readGoal(agentDir) { |
| 45 | + const root = join(agentDir, "extensions", "goal"); |
| 46 | + const files = findJsonFiles(root); |
| 47 | + if (files.length !== 1) throw new Error(`Expected one goal under ${root}, found ${files.length}`); |
| 48 | + const parsed = JSON.parse(readFileSync(files[0], "utf8")); |
| 49 | + const goal = parsed?.goal ?? parsed; |
| 50 | + if (!goal || typeof goal.status !== "string") throw new Error(`Invalid goal record in ${files[0]}`); |
| 51 | + return goal; |
| 52 | +} |
| 53 | + |
| 54 | +function toolGoal(events, toolCallId) { |
| 55 | + const event = events.find( |
| 56 | + (candidate) => candidate.type === "tool_execution_end" && candidate.toolCallId === toolCallId, |
| 57 | + ); |
| 58 | + const text = event?.result?.content?.find((part) => part.type === "text")?.text; |
| 59 | + if (typeof text !== "string") throw new Error(`Missing result for ${toolCallId}`); |
| 60 | + const goal = JSON.parse(text)?.goal; |
| 61 | + if (!goal || typeof goal.status !== "string") throw new Error(`Invalid goal result for ${toolCallId}`); |
| 62 | + return goal; |
| 63 | +} |
| 64 | + |
| 65 | +function safeRequests(requests) { |
| 66 | + return requests.map((request, index) => ({ |
| 67 | + index: index + 1, |
| 68 | + method: request.method, |
| 69 | + url: request.url, |
| 70 | + model: request.model, |
| 71 | + messageCount: Array.isArray(request.messages) ? request.messages.length : null, |
| 72 | + })); |
| 73 | +} |
| 74 | + |
| 75 | +function writeJson(path, value) { |
| 76 | + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); |
| 77 | +} |
| 78 | + |
| 79 | +async function runPrompt(client, message) { |
| 80 | + const afterIndex = client.events.length; |
| 81 | + const terminalPromise = client.waitForEvent( |
| 82 | + (event) => event.type === "agent_end" || event.type === "agent_aborted", |
| 83 | + afterIndex, |
| 84 | + 60_000, |
| 85 | + ); |
| 86 | + const acknowledgement = await client.send({ type: "prompt", message }, 15_000); |
| 87 | + return { acknowledgement, terminal: await terminalPromise }; |
| 88 | +} |
| 89 | + |
| 90 | +async function main() { |
| 91 | + if (!process.argv.includes("--self-test")) { |
| 92 | + process.stderr.write("usage: goal-provider-error-recovery.mjs --self-test [--evidence SLUG]\n"); |
| 93 | + process.exitCode = 2; |
| 94 | + return; |
| 95 | + } |
| 96 | + |
| 97 | + installCleanupHooks(); |
| 98 | + const checks = createChecks("goal-provider-error-recovery.mjs --self-test"); |
| 99 | + const evidence = evidenceDir(flag("--evidence") ?? "goal-provider-error-recovery"); |
| 100 | + const authGuard = guardRealAuth(); |
| 101 | + const box = makeSandbox("senpi-qa-goal-provider-error-recovery"); |
| 102 | + const preset = API_PRESETS["openai-completions"]; |
| 103 | + const observed = { terminals: [], goals: {}, requestCount: 0, localhostOnly: false }; |
| 104 | + let server; |
| 105 | + let client; |
| 106 | + let rpcExitCode = null; |
| 107 | + let realAuthUnchanged = false; |
| 108 | + |
| 109 | + try { |
| 110 | + server = await startFakeModelServer({ turns: TURNS }); |
| 111 | + writeMockModelsJson(box.agentDir, server, "openai-completions", {}, { |
| 112 | + retry: { |
| 113 | + enabled: false, |
| 114 | + maxRetries: 0, |
| 115 | + baseDelayMs: 0, |
| 116 | + provider: { maxRetries: 0, maxRetryDelayMs: 0 }, |
| 117 | + fallbackChains: {}, |
| 118 | + }, |
| 119 | + }); |
| 120 | + client = new RpcQaClient({ |
| 121 | + env: hermeticEnv(box.env), |
| 122 | + cwd: box.cwd, |
| 123 | + extraArgs: ["--provider", preset.provider, "--model", preset.modelId], |
| 124 | + }); |
| 125 | + |
| 126 | + const state = await client.send({ type: "get_state" }); |
| 127 | + checks.ok("RPC booted in the isolated sandbox", state.success === true && state.command === "get_state"); |
| 128 | + |
| 129 | + const first = await runPrompt(client, "Create the scripted recovery goal and begin it."); |
| 130 | + observed.terminals.push(first.terminal.type); |
| 131 | + checks.ok( |
| 132 | + "provider-error turn ended through agent_end", |
| 133 | + first.acknowledgement.success === true && first.terminal.type === "agent_end", |
| 134 | + ); |
| 135 | + observed.goals.providerError = readGoal(box.agentDir); |
| 136 | + checks.ok("provider error stopped after exactly two localhost requests", server.requests.length === 2); |
| 137 | + checks.ok( |
| 138 | + "provider error persisted the mechanical blocked reason", |
| 139 | + observed.goals.providerError.status === "blocked" && |
| 140 | + observed.goals.providerError.blockedReason === PROVIDER_ERROR_REASON, |
| 141 | + ); |
| 142 | + |
| 143 | + const second = await runPrompt( |
| 144 | + client, |
| 145 | + "Retry the goal now, inspect its status, then apply the scripted intentional block.", |
| 146 | + ); |
| 147 | + observed.terminals.push(second.terminal.type); |
| 148 | + checks.ok( |
| 149 | + "next direct prompt was accepted", |
| 150 | + second.acknowledgement.success === true && second.terminal.type === "agent_end", |
| 151 | + ); |
| 152 | + observed.goals.reactivated = toolGoal(client.events, "qa-get-reactivated-goal"); |
| 153 | + checks.ok( |
| 154 | + "get_goal observed active before the next model action", |
| 155 | + observed.goals.reactivated.status === "active" && |
| 156 | + observed.goals.reactivated.blockedReason === undefined, |
| 157 | + ); |
| 158 | + observed.goals.intentional = readGoal(box.agentDir); |
| 159 | + checks.ok( |
| 160 | + "model-authored update_goal persisted an intentional block", |
| 161 | + observed.goals.intentional.status === "blocked" && |
| 162 | + observed.goals.intentional.blockedReason === INTENTIONAL_REASON, |
| 163 | + ); |
| 164 | + |
| 165 | + const third = await runPrompt( |
| 166 | + client, |
| 167 | + "Inspect the intentionally blocked goal without explicitly resuming it.", |
| 168 | + ); |
| 169 | + observed.terminals.push(third.terminal.type); |
| 170 | + checks.ok( |
| 171 | + "later direct prompt was accepted", |
| 172 | + third.acknowledgement.success === true && third.terminal.type === "agent_end", |
| 173 | + ); |
| 174 | + observed.goals.toolPreserved = toolGoal(client.events, "qa-get-still-blocked-goal"); |
| 175 | + observed.goals.preserved = readGoal(box.agentDir); |
| 176 | + checks.ok( |
| 177 | + "get_goal observed the intentional block unchanged", |
| 178 | + observed.goals.toolPreserved.status === "blocked" && |
| 179 | + observed.goals.toolPreserved.blockedReason === INTENTIONAL_REASON && |
| 180 | + observed.goals.preserved.id === observed.goals.intentional.id && |
| 181 | + observed.goals.preserved.status === "blocked" && |
| 182 | + observed.goals.preserved.blockedReason === INTENTIONAL_REASON && |
| 183 | + Number.isFinite(observed.goals.intentional.blockedAt) && |
| 184 | + observed.goals.preserved.blockedAt === observed.goals.intentional.blockedAt, |
| 185 | + ); |
| 186 | + |
| 187 | + observed.requestCount = server.requests.length; |
| 188 | + checks.ok("scripted run made exactly seven localhost provider requests", observed.requestCount === 7); |
| 189 | + observed.localhostOnly = |
| 190 | + server.origin.startsWith("http://127.0.0.1:") && |
| 191 | + server.requests.every( |
| 192 | + (request) => request.method === "POST" && request.url?.endsWith("/chat/completions"), |
| 193 | + ); |
| 194 | + checks.ok("zero real provider calls", observed.localhostOnly, server.origin); |
| 195 | + } catch (error) { |
| 196 | + checks.ok("scenario completed without an exception", false, error instanceof Error ? error.message : String(error)); |
| 197 | + } finally { |
| 198 | + if (client) { |
| 199 | + client.close(); |
| 200 | + try { |
| 201 | + rpcExitCode = await client.waitForExit(5_000); |
| 202 | + } catch { |
| 203 | + client.kill(); |
| 204 | + rpcExitCode = await client.waitForExit(5_000).catch(() => null); |
| 205 | + } |
| 206 | + checks.ok("RPC process exited after stdin closed", rpcExitCode === 0, `exitCode=${rpcExitCode}`); |
| 207 | + } |
| 208 | + if (server) await server.stop(); |
| 209 | + try { |
| 210 | + realAuthUnchanged = authGuard.assertUnchanged(); |
| 211 | + } catch { |
| 212 | + realAuthUnchanged = false; |
| 213 | + } |
| 214 | + checks.ok("real auth unchanged", realAuthUnchanged, authGuard.path); |
| 215 | + box.cleanup(); |
| 216 | + checks.ok("isolated sandbox removed", !existsSync(box.dir), box.dir); |
| 217 | + } |
| 218 | + |
| 219 | + const pass = checks.finish(); |
| 220 | + writeJson(join(evidence, "summary.json"), { |
| 221 | + pass, |
| 222 | + terminalEvents: observed.terminals, |
| 223 | + providerRequestCount: observed.requestCount, |
| 224 | + providerErrorStatus: observed.goals.providerError?.status ?? null, |
| 225 | + reactivatedStatus: observed.goals.reactivated?.status ?? null, |
| 226 | + intentionalStatus: observed.goals.preserved?.status ?? null, |
| 227 | + blockedAtUnchanged: |
| 228 | + observed.goals.intentional?.blockedAt === observed.goals.preserved?.blockedAt, |
| 229 | + localhostOnly: observed.localhostOnly, |
| 230 | + realAuthUnchanged, |
| 231 | + rpcExitCode, |
| 232 | + serverStopped: server !== undefined, |
| 233 | + sandboxRemoved: !existsSync(box.dir), |
| 234 | + }); |
| 235 | + writeFileSync( |
| 236 | + join(evidence, "rpc-events.jsonl"), |
| 237 | + `${(client?.events ?? []).map((event) => JSON.stringify(event)).join("\n")}\n`, |
| 238 | + ); |
| 239 | + for (const [name, goal] of Object.entries(observed.goals)) { |
| 240 | + writeJson(join(evidence, `goal-${name}.json`), goal); |
| 241 | + } |
| 242 | + writeJson(join(evidence, "mock-request-summary.json"), safeRequests(server?.requests ?? [])); |
| 243 | + process.stdout.write(`Evidence: ${evidence}\n`); |
| 244 | + process.exitCode = pass ? 0 : 1; |
| 245 | +} |
| 246 | + |
| 247 | +await main(); |
0 commit comments