#!/usr/bin/env node // S4: Pi abort once — API abort works; client reducer is idempotent (static + mock API). // Requires mnote-web with MNOTE_WEB_ALLOW_DEV_FIXTURES=1 + MNOTE_PAGE_AI_PI_LAB_RUNTIME=mock. "use strict"; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const BASE = process.env.MNOTE_S4_BASE || process.env.MNOTE_PI_LAB_BASE || "http://127.0.0.1:3017"; const ACTOR_ID = process.env.MNOTE_S4_ACTOR_ID || "s4-abort-admin"; const AUTH = process.env.MNOTE_S4_AUTH || "Bearer s4-abort"; const ROOT = process.env.MNOTE_S4_ROOT || fs.mkdtempSync(path.join(os.tmpdir(), "mnote-s4-abort-")); function assert(condition, message) { if (!condition) throw new Error(message); } async function fetchJson(url, options = {}) { const headers = { "Content-Type": "application/json", Authorization: AUTH, "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "admin", ...(options.headers || {}), }; const res = await fetch(url, { ...options, headers }); const text = await res.text(); let body = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; } return { status: res.status, body }; } async function seedWorkspace(rootUri, rootPath) { const seed = await fetchJson(`${BASE}/api/dev/seed`, { method: "POST", body: JSON.stringify({ seeds: [ { kind: "setupWorkspace", user_id: ACTOR_ID, email: `${ACTOR_ID}@example.com`, username: ACTOR_ID, display_name: ACTOR_ID, role: "admin", password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!", workspace_id: `local-ws:${ACTOR_ID}:s4`, workspace_name: "S4 abort", root_uri: rootUri, root_path: rootPath, source_kind: "local_folder", permission: "write", capabilities: ["ai"], grant_source: "s4_abort_smoke", grant_created_by: ACTOR_ID, }, ], }), }); if (seed.status === 403 && seed.body && seed.body.code === "dev_seed_disabled") { throw new Error( "S4 smoke 需要 /api/dev/seed;请以 MNOTE_WEB_ALLOW_DEV_FIXTURES=1 启动 mnote-web。", ); } assert( seed.status === 200 && seed.body.ok === true, `/api/dev/seed failed: ${seed.status} ${JSON.stringify(seed.body)}`, ); } function staticClientChecks() { const runtimePath = path.join( __dirname, "..", "rust/crates/mnote-web/browser/sidebar-page-ai-pi-lab-runtime.js", ); const src = fs.readFileSync(runtimePath, "utf8"); assert( src.includes("assistant_abort") && src.includes("STATE_ABORTED"), "client must handle assistant_abort → STATE_ABORTED", ); assert( src.includes("abort is idempotent") || (src.includes("runtimeStatus === STATE_ABORTED") && src.includes("assistant_abort")), "client reducer must short-circuit second assistant_abort", ); // Optimistic single apply before fire-and-forget ABORT. assert( /function abortPrompt[\s\S]*?applyPiRunEvent\(\{\s*type:\s*'assistant_abort'/.test(src) || /function abortPrompt[\s\S]*?applyPiRunEvent\(\{ type: 'assistant_abort'/.test(src), "abortPrompt must optimistically apply assistant_abort once", ); assert( /function abortPrompt[\s\S]*?fetch\(API\.ABORT/.test(src) || /function abortPrompt[\s\S]*?API\.ABORT/.test(src), "abortPrompt must still call ABORT API", ); console.log(" [static] client abort once + idempotent reducer OK"); } async function main() { staticClientChecks(); fs.mkdirSync(ROOT, { recursive: true }); fs.writeFileSync(path.join(ROOT, "s4.md"), "# S4 abort\n", "utf8"); const rootUri = `file://${ROOT}`; console.log(`\n🧪 S4 Pi abort idempotent (base=${BASE}, actor=${ACTOR_ID})\n`); await seedWorkspace(rootUri, ROOT); const status = await fetchJson(`${BASE}/api/page-ai/pi/status`); assert(status.status === 200 && status.body.ok === true, `status: ${status.status}`); if (status.body.runtimeMode && status.body.runtimeMode !== "mock") { console.log(` ⚠ runtimeMode=${status.body.runtimeMode} (prefer mock for this smoke)`); } const sessionId = `pi_lab_s4_${Date.now()}`; const start = await fetchJson(`${BASE}/api/page-ai/pi/start`, { method: "POST", body: JSON.stringify({ sessionId, rootUri, workspaceId: `local-ws:${ACTOR_ID}:s4`, pagePath: "s4.md", pageTitle: "S4 abort", }), }); assert(start.status === 200 && start.body.ok === true, `start: ${start.status} ${JSON.stringify(start.body)}`); const sid = (start.body.session && start.body.session.sessionId) || sessionId; console.log(` started ${sid}`); // Send may stream; abort immediately after. const send = await fetchJson(`${BASE}/api/page-ai/pi/send`, { method: "POST", body: JSON.stringify({ sessionId: sid, message: "S4 long answer please keep streaming", rootUri, }), }); // Mock may accept or complete quickly — either is fine before abort. assert( send.status === 200 || send.status === 409 || send.status === 400, `send unexpected: ${send.status} ${JSON.stringify(send.body)}`, ); console.log(` send status=${send.status}`); const abort1 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId: sid }), }); assert( abort1.status === 200 && abort1.body.ok === true && abort1.body.aborted === true, `abort1: ${abort1.status} ${JSON.stringify(abort1.body)}`, ); assert(abort1.body.stopReason === "aborted" || abort1.body.aborted === true, "abort1 stopReason"); console.log(" abort1 ok"); // Second abort must still succeed (idempotent server) without double-error. const abort2 = await fetchJson(`${BASE}/api/page-ai/pi/abort`, { method: "POST", body: JSON.stringify({ sessionId: sid }), }); assert( abort2.status === 200 && abort2.body.ok === true && abort2.body.aborted === true, `abort2 must be idempotent: ${abort2.status} ${JSON.stringify(abort2.body)}`, ); console.log(" abort2 idempotent ok"); console.log("\n✅ S4 Pi abort idempotent smoke passed\n"); } main().catch((err) => { console.error("\n❌ S4 smoke failed:", err.message || err); process.exit(1); });