${scored ? `${summary.failed} of ${judged} evaluators breached` : "Nothing was evaluated"}
Evaluators Passed
@@ -1598,9 +1740,11 @@ function generateHtmlReport(report) {
${
- overallVerdict === "PASS"
- ? `The target system ${escapeHtml(target.name)}passed all ${summary.totalTests} evaluator${summary.totalTests === 1 ? "" : "s"} in the ${escapeHtml(metadata.configId)} suite. No exploitable vulnerabilities were surfaced under sustained adversarial pressure with the configured turn budget. The system demonstrates adequate resistance to the evaluated attack patterns at the time of assessment.`
- : `The target system ${escapeHtml(target.name)}failed ${summary.failed} of ${summary.totalTests} evaluator${summary.totalTests === 1 ? "" : "s"} (${summary.attackSuccessRate}% attack success rate) in the ${escapeHtml(metadata.configId)} suite. ${summary.failed === 1 ? "One vulnerability was" : "Multiple vulnerabilities were"} surfaced under adversarial pressure.${criticalFindings.length > 0 ? ` ${criticalFindings.length} critical finding${criticalFindings.length === 1 ? "" : "s"} require immediate remediation.` : ""} Refer to the Findings section for a prioritised remediation plan.`
+ overallVerdict === "CANCELLED"
+ ? `⚠ Run cancelled by user. The assessment of ${escapeHtml(target.name)} in the ${escapeHtml(metadata.configId)} suite was stopped before all evaluators could complete. Of ${summary.totalTests} evaluator${summary.totalTests === 1 ? "" : "s"} recorded, ${summary.passed} passed and ${summary.failed} failed${summary.cancelled ? `, and ${summary.cancelled} ${summary.cancelled === 1 ? "was" : "were"} interrupted mid-evaluation` : ""}. This report reflects only the evaluators completed up to the point of cancellation.`
+ : overallVerdict === "PASS"
+ ? `The target system ${escapeHtml(target.name)}passed all ${summary.totalTests} evaluator${summary.totalTests === 1 ? "" : "s"} in the ${escapeHtml(metadata.configId)} suite. No exploitable vulnerabilities were surfaced under sustained adversarial pressure with the configured turn budget. The system demonstrates adequate resistance to the evaluated attack patterns at the time of assessment.`
+ : `The target system ${escapeHtml(target.name)}failed ${summary.failed} of ${summary.totalTests} evaluator${summary.totalTests === 1 ? "" : "s"} (${summary.attackSuccessRate}% attack success rate) in the ${escapeHtml(metadata.configId)} suite. ${summary.failed === 1 ? "One vulnerability was" : "Multiple vulnerabilities were"} surfaced under adversarial pressure.${criticalFindings.length > 0 ? ` ${criticalFindings.length} critical finding${criticalFindings.length === 1 ? "" : "s"} require immediate remediation.` : ""} Refer to the Findings section for a prioritised remediation plan.`
}
@@ -1787,8 +1931,11 @@ async function setReportHistory(items) {
async function addReportToHistory(report) {
if (!report?.metadata?.reportId) return;
- const verdict =
- report?.summary?.failed === 0 && report?.summary?.totalTests > 0 ? "PASS" : "FAIL";
+ const verdict = report?.cancelled
+ ? "CANCELLED"
+ : report?.summary?.failed === 0 && report?.summary?.totalTests > 0
+ ? "PASS"
+ : "FAIL";
const item = {
id: report.metadata.reportId,
generated: report.metadata.generated,
@@ -1864,10 +2011,13 @@ async function downloadReport() {
if (opforLastResult?.judgment) {
const verdict =
- String(opforLastResult.judgment.verdict || "FAIL").toUpperCase() === "PASS"
- ? "PASS"
- : "FAIL";
- const partialNote = opforLastResult.partial ? " (partial run)" : "";
+ opforLastResult.stopReason === "user_stop"
+ ? "CANCELLED"
+ : String(opforLastResult.judgment.verdict || "FAIL").toUpperCase() === "PASS"
+ ? "PASS"
+ : "FAIL";
+ const partialNote =
+ opforLastResult.partial && verdict !== "CANCELLED" ? " (partial run)" : "";
state.results = [
{
id: opforLastResult.evaluatorId || "unknown",
@@ -1880,15 +2030,18 @@ async function downloadReport() {
];
} else if (opforLastResult?.transcript?.length >= 2) {
const turnCount = opforLastResult.transcript.length;
+ const wasCancelled = opforLastResult.stopReason === "user_stop";
state.results = [
{
id: opforLastResult.evaluatorId || "unknown",
name: opforLastResult.evaluatorName || "Evaluator",
sev: normalizeSev(opforLastResult.severity),
- verdict: "FAIL",
- summary: opforLastResult.errorMessage
- ? `Run failed after ${Math.floor(turnCount / 2)} turns: ${opforLastResult.errorMessage}`
- : `Run ended with ${Math.floor(turnCount / 2)} turns but no judgment was produced.`,
+ verdict: wasCancelled ? "CANCELLED" : "FAIL",
+ summary: wasCancelled
+ ? `Cancelled by user after ${Math.floor(turnCount / 2)} turns.`
+ : opforLastResult.errorMessage
+ ? `Run failed after ${Math.floor(turnCount / 2)} turns: ${opforLastResult.errorMessage}`
+ : `Run ended with ${Math.floor(turnCount / 2)} turns but no judgment was produced.`,
raw: opforLastResult,
},
];
@@ -2035,15 +2188,17 @@ function renderHistoryList(items) {
const id = item?.id || report?.metadata?.reportId || "";
const cfg = item?.configId || report?.metadata?.configId || "";
const sum = item?.summary || report?.summary || {};
- const total = Number(sum.totalEvaluators ?? sum.totalTests ?? 0) || 0;
+ // Judged-only count, so a cancelled evaluator doesn't dilute "X/Y passed".
+ const total = judgedCount(sum);
const failed = Number(sum.failed ?? 0) || 0;
const passed = Number(sum.passed ?? 0) || 0;
const gen = item?.generated || report?.metadata?.generated || "";
const allPassed = failed === 0 && total > 0;
+ const isCancelled = Boolean(item?.verdict === "CANCELLED" || report?.cancelled);
const wrap = document.createElement("div");
wrap.className = "history-item";
- wrap.dataset.status = allPassed ? "pass" : "fail";
+ wrap.dataset.status = isCancelled ? "cancelled" : allPassed ? "pass" : "fail";
const stripe = document.createElement("div");
stripe.className = "accent-stripe";
@@ -2214,16 +2369,25 @@ async function runOneEvaluator(ev, { resume = false } = {}) {
},
};
}
- if (directResult?.paused) {
+ // `paused` means "interrupted, resumable snapshot saved"; `cancelled` means
+ // "interrupted, discarded". The background tags which one it was — never
+ // infer it here.
+ if (directResult?.paused || directResult?.cancelled) {
stopCosmeticTicker();
- return { paused: true, error: directResult.error };
+ return {
+ paused: !!directResult.paused,
+ cancelled: !!directResult.cancelled,
+ error: directResult.error,
+ };
}
// Otherwise poll storage — the service worker persists results there.
const result = await pollStorageForResult(ev.id);
stopCosmeticTicker();
- if (result?.paused) return { paused: true, error: result.error };
+ if (result?.paused || result?.cancelled) {
+ return { paused: !!result.paused, cancelled: !!result.cancelled, error: result.error };
+ }
if (!result?.ok) {
const errMsg = result?.error || directResult?.error || "Unknown error";
return { error: errMsg };
@@ -2232,20 +2396,56 @@ async function runOneEvaluator(ev, { resume = false } = {}) {
setPhase("judging");
await new Promise((r) => setTimeout(r, 250));
+ // A user-cancelled run is persisted with ok:true (it's a valid partial
+ // result, not an error) — never coerce that into PASS/FAIL. Key off
+ // stopReason, NOT `stopped`: a "recovered" partial is also flagged stopped
+ // but WAS genuinely judged, and must keep its real verdict.
const verdict =
- String(result.judgment?.verdict || "FAIL").toUpperCase() === "PASS" ? "PASS" : "FAIL";
+ result.stopReason === "user_stop"
+ ? "CANCELLED"
+ : String(result.judgment?.verdict || "FAIL").toUpperCase() === "PASS"
+ ? "PASS"
+ : "FAIL";
return {
record: {
id: ev.id,
name: ev.name,
sev: ev.sev,
verdict,
- summary: result.judgment?.summary || "",
+ summary:
+ result.judgment?.summary ||
+ (verdict === "CANCELLED" ? "Cancelled by user before this evaluator could finish." : ""),
raw: result,
},
};
}
+/**
+ * Does a persisted record belong to the evaluator we're waiting on?
+ * Records written by a *previous* evaluator linger in storage, so anything
+ * read out of it must be ownership-checked before being treated as this
+ * evaluator's result. Records with no evaluatorId at all are accepted as a
+ * last resort (older writes, and error records the background couldn't tag).
+ */
+function matchesEvaluator(record, evaluatorId) {
+ if (!record) return false;
+ if (!record.evaluatorId) return true;
+ return record.evaluatorId === evaluatorId;
+}
+
+/**
+ * True once the user has asked to stop or pause.
+ *
+ * Judging a partial transcript is an LLM call, and it must never run in that
+ * case: a cancelled evaluator has no verdict to report, the call adds seconds
+ * to a stop the user wants immediate, and `OPFOR_JUDGE_PARTIAL` overwrites
+ * `opforLastResult` with `stopped: true` + real judge reasoning — which is how
+ * a cancelled row ended up carrying a full PASS/FAIL-style rationale.
+ */
+function userInterrupted() {
+ return state.cancelRequested || state.pauseRequested;
+}
+
/**
* Poll chrome.storage.local for a completed or judged result.
* The service worker persists results to `opforLastResult` and live transcripts
@@ -2258,9 +2458,18 @@ async function pollStorageForResult(evaluatorId) {
const pollStart = Date.now();
let seenRunning = false;
+ // How long to keep waiting for the background's own stop/pause record after
+ // the user interrupts. The service worker flips run status to not-running
+ // the moment it receives the stop, well before the orchestrator has unwound
+ // and written its record — without this grace the poller would race past it.
+ const INTERRUPT_GRACE_MS = 12_000;
+ let interruptedAt = 0;
+
while (Date.now() - pollStart < ABS_MAX_MS) {
await new Promise((r) => setTimeout(r, POLL_MS));
+ if (userInterrupted() && !interruptedAt) interruptedAt = Date.now();
+
let data;
try {
data = await chrome.storage.local.get([
@@ -2272,12 +2481,22 @@ async function pollStorageForResult(evaluatorId) {
continue;
}
- // 1. Best case: completed result with judgment
- const last = data.opforLastResult;
- if (last?.judgment) {
- if (last.evaluatorId === evaluatorId || last.completed) return last;
+ // 0. User interrupted: wait only for the background's own record, and
+ // never fall through to the partial-judge steps below.
+ if (interruptedAt) {
+ const rec = data.opforLastResult;
+ if (rec && matchesEvaluator(rec, evaluatorId) && (rec.judgment || rec.stopReason)) return rec;
+ if (Date.now() - interruptedAt < INTERRUPT_GRACE_MS) continue;
+ return { paused: state.pauseRequested, cancelled: state.cancelRequested };
}
+ // 1. Best case: completed result with judgment.
+ // Must belong to THIS evaluator — a previous evaluator's `completed`
+ // result left in storage would otherwise be returned immediately,
+ // making this evaluator appear to finish instantly with its verdict.
+ const last = data.opforLastResult;
+ if (last?.judgment && matchesEvaluator(last, evaluatorId)) return last;
+
const status = data.opforRunStatus;
// 2. Run still active — keep polling
@@ -2292,23 +2511,28 @@ async function pollStorageForResult(evaluatorId) {
await new Promise((r) => setTimeout(r, 1500));
try {
const fresh = await chrome.storage.local.get(["opforLastResult"]);
- if (fresh.opforLastResult?.judgment) return fresh.opforLastResult;
+ if (fresh.opforLastResult?.judgment && matchesEvaluator(fresh.opforLastResult, evaluatorId))
+ return fresh.opforLastResult;
} catch {
/* swallowed */
}
}
// 4. Completed result with judgment (re-check after settle)
- if (last?.judgment) return last;
+ if (last?.judgment && matchesEvaluator(last, evaluatorId)) return last;
// 5. Service worker returned an explicit error (ok: false, no judgment)
- if (last && last.ok === false && last.errorMessage) {
+ if (last && last.ok === false && last.errorMessage && matchesEvaluator(last, evaluatorId)) {
return { ok: false, error: last.errorMessage };
}
// 6. Live transcript available — ask service worker to judge it
const live = data.opforLiveTranscript;
- if (live?.transcript?.length >= 2) {
+ if (
+ !userInterrupted() &&
+ live?.transcript?.length >= 2 &&
+ matchesEvaluator(live, evaluatorId)
+ ) {
try {
const judged = await chrome.runtime.sendMessage({
type: "OPFOR_JUDGE_PARTIAL",
@@ -2354,12 +2578,17 @@ async function pollStorageForResult(evaluatorId) {
// Final attempt
try {
const data = await chrome.storage.local.get(["opforLastResult", "opforLiveTranscript"]);
- if (data.opforLastResult?.judgment) return data.opforLastResult;
- if (data.opforLastResult?.errorMessage)
- return { ok: false, error: data.opforLastResult.errorMessage };
+ const finalLast = data.opforLastResult;
+ if (finalLast?.judgment && matchesEvaluator(finalLast, evaluatorId)) return finalLast;
+ if (finalLast?.errorMessage && matchesEvaluator(finalLast, evaluatorId))
+ return { ok: false, error: finalLast.errorMessage };
const live = data.opforLiveTranscript;
- if (live?.transcript?.length >= 2) {
+ if (
+ !userInterrupted() &&
+ live?.transcript?.length >= 2 &&
+ matchesEvaluator(live, evaluatorId)
+ ) {
try {
const judged = await chrome.runtime.sendMessage({
type: "OPFOR_JUDGE_PARTIAL",
@@ -2388,6 +2617,7 @@ async function startRun({ resume = false } = {}) {
state.running = true;
state.cancelRequested = false;
state.pauseRequested = false;
+ state.runCancelled = false;
state.lastReport = null;
await saveModelAndKey();
@@ -2456,6 +2686,44 @@ async function startRun({ resume = false } = {}) {
const out = await runOneEvaluator(ev, { resume: resume && isFirst });
resume = false; // only resume the first evaluator on a resume
+ // The background reports ANY aborted turn as `paused: true` — it's the
+ // same generic signal for a real Pause and a user Stop. Only route to
+ // the resumable Paused screen when the user actually asked to pause;
+ // a Stop always means cancel-and-report, even if out.paused is set.
+ if (state.cancelRequested) {
+ let cancelledRecord = out.record || null;
+ if (!cancelledRecord) {
+ let opforLastResult = null;
+ try {
+ ({ opforLastResult } = await chrome.storage.local.get("opforLastResult"));
+ } catch {
+ /* swallowed */
+ }
+ const matches = opforLastResult?.evaluatorId === ev.id;
+ const j = matches ? opforLastResult.judgment : null;
+ cancelledRecord = {
+ id: ev.id,
+ name: ev.name,
+ sev: ev.sev,
+ verdict: "CANCELLED",
+ summary: j?.summary || "Cancelled by user before this evaluator could finish.",
+ raw: matches ? opforLastResult : null,
+ };
+ }
+ state.results.push(cancelledRecord);
+ state.evIdx++;
+ // A Stop always discards — never leave behind the resumable snapshot
+ // the background persists for ANY aborted turn (it can't tell Stop
+ // from Pause). Safe to do now: persistence happens before the
+ // background replies, so runOneEvaluator() resolving here guarantees
+ // it already wrote (or didn't write) whatever it was going to write.
+ try {
+ await chrome.runtime.sendMessage({ type: "OPFOR_UI_DISCARD_PAUSED" });
+ } catch {
+ /* swallowed */
+ }
+ break;
+ }
if (state.pauseRequested || out.paused) {
state.running = false;
try {
@@ -2465,16 +2733,20 @@ async function startRun({ resume = false } = {}) {
}
state.keepAlivePort = null;
stopCosmeticTicker();
- await clearPopupRunQueue();
- $("pausedSuite").textContent = state.suiteId;
- $("pausedEvaluator").textContent = ev.name;
- $("pausedModel").textContent = state.model;
- $("pausedSub").textContent = `evaluator ${state.evIdx + 1} of ${state.queue.length} · saved`;
- $("pausedElapsed").textContent = "—";
+ // Keep the queue — parked, not cleared. Clearing it here is why resuming
+ // used to drop every evaluator after the paused one: the popup could
+ // only rebuild a single-evaluator queue from the paused snapshot.
+ await persistPopupRunQueue({ running: false, paused: true });
+ let pausedRun = null;
+ try {
+ ({ opforPausedRun: pausedRun } = await chrome.storage.local.get("opforPausedRun"));
+ } catch {
+ /* swallowed */
+ }
+ renderPausedScreen(ev, pausedRun);
setScreen("paused");
return;
}
- if (state.cancelRequested) break;
if (out.error) {
let recovered = null;
try {
@@ -2499,7 +2771,7 @@ async function startRun({ resume = false } = {}) {
summary: opforLastResult.judgment.summary || "",
raw: opforLastResult,
};
- } else if (live?.transcript?.length >= 2) {
+ } else if (!userInterrupted() && live?.transcript?.length >= 2) {
try {
const judged = await chrome.runtime.sendMessage({
type: "OPFOR_JUDGE_PARTIAL",
@@ -2579,10 +2851,22 @@ async function startRun({ resume = false } = {}) {
await clearPopupRunQueue();
if (state.cancelRequested) {
- state.queue = [];
- state.results = [];
- state.evIdx = 0;
- setScreen("idle");
+ try {
+ await chrome.runtime.sendMessage({ type: "OPFOR_UI_DISCARD_PAUSED" });
+ } catch {
+ /* swallowed */
+ }
+ if (state.results.length) {
+ state.runCancelled = true;
+ await finalizeAndPersistCurrentReport();
+ renderDone();
+ setScreen("done");
+ } else {
+ state.queue = [];
+ state.results = [];
+ state.evIdx = 0;
+ setScreen("idle");
+ }
return;
}
@@ -2594,58 +2878,67 @@ async function startRun({ resume = false } = {}) {
async function requestPause() {
if (!state.running) return;
state.pauseRequested = true;
+ setPauseBusy(true);
stopRunStatusPoller();
- await clearPopupRunQueue();
+ // Park the queue rather than clearing it — if the popup closes before the
+ // loop reaches its pause branch, the rest of the suite must still survive.
+ await persistPopupRunQueue({ running: false, paused: true });
try {
- await chrome.runtime.sendMessage({ type: "OPFOR_UI_STOP" });
+ await chrome.runtime.sendMessage({ type: "OPFOR_UI_STOP", intent: "pause" });
} catch {
/* swallowed */
}
+
+ // Same reasoning as requestStop(): the still-running startRun() loop's
+ // in-flight runOneEvaluator() call will detect state.pauseRequested and
+ // transition to the resumable Paused screen once it resolves.
+ const phaseText = $("runPhaseText");
+ if (phaseText) phaseText.textContent = "Pausing — finishing current evaluator…";
}
async function requestStop() {
state.cancelRequested = true;
state.pauseRequested = false;
+ setStopBusy(true);
stopRunStatusPoller();
await clearPopupRunQueue();
try {
- await chrome.runtime.sendMessage({ type: "OPFOR_UI_STOP" });
+ await chrome.runtime.sendMessage({ type: "OPFOR_UI_STOP", intent: "cancel" });
} catch {
/* swallowed */
}
- // If the service worker saved a partial result, surface it on the Done screen.
- try {
- const { opforLastResult } = await chrome.storage.local.get("opforLastResult");
- if (opforLastResult?.partial) {
- const cur = state.queue[state.evIdx];
- state.results.push({
- id: cur?.id || "partial",
- name: cur?.name || "Partial result",
- sev: cur?.sev || "low",
- verdict: "FAIL",
- summary: "Stopped by user (partial result saved).",
- raw: opforLastResult,
- });
- state.evIdx = Math.min(state.evIdx + 1, state.queue.length);
- await finalizeAndPersistCurrentReport();
- renderDone();
- setScreen("done");
- stopCosmeticTicker();
- state.running = false;
- return;
- }
- } catch {
- /* swallowed */
+ // Give the user immediate feedback — the actual Cancelled report is
+ // finalized by the still-running startRun() loop once its in-flight
+ // runOneEvaluator() call resolves (guaranteed to happen: the background
+ // persists the partial result before it replies). Racing ahead to read
+ // storage here would only see it some of the time.
+ if (state.running) {
+ const phaseText = $("runPhaseText");
+ if (phaseText) phaseText.textContent = "Stopping — finishing current evaluator…";
+ return;
}
+ // Nothing in flight — e.g. Stop pressed from the Paused screen. Discard the
+ // resumable snapshot and the parked queue, but still surface a report for
+ // whatever already completed rather than silently throwing it away.
try {
await chrome.runtime.sendMessage({ type: "OPFOR_UI_DISCARD_PAUSED" });
} catch {
/* swallowed */
}
+ await clearPopupRunQueue();
stopCosmeticTicker();
state.running = false;
+
+ if (state.results.length) {
+ state.runCancelled = true;
+ await finalizeAndPersistCurrentReport();
+ renderDone();
+ setScreen("done");
+ return;
+ }
+
state.queue = [];
state.results = [];
state.evIdx = 0;
@@ -2658,6 +2951,9 @@ async function discardPaused() {
} catch {
/* swallowed */
}
+ // Drop the parked queue too, or the next popup open would offer to resume
+ // a run whose snapshot is already gone.
+ await clearPopupRunQueue();
state.queue = [];
state.results = [];
state.evIdx = 0;
@@ -2665,11 +2961,24 @@ async function discardPaused() {
}
async function cancelAwaitUser() {
+ state.cancelRequested = true;
+ state.pauseRequested = false;
try {
- await chrome.runtime.sendMessage({ type: "OPFOR_UI_STOP" });
+ await chrome.runtime.sendMessage({ type: "OPFOR_UI_STOP", intent: "cancel" });
} catch {
/* swallowed */
}
+
+ // Same reasoning as requestStop(): the still-running startRun() loop's
+ // in-flight runOneEvaluator() call will detect the cancellation and
+ // finalize a Cancelled report once it resolves.
+ if (state.running) {
+ setScreen("running");
+ const phaseText = $("runPhaseText");
+ if (phaseText) phaseText.textContent = "Stopping — finishing current evaluator…";
+ return;
+ }
+
stopCosmeticTicker();
state.running = false;
state.queue = [];
@@ -3063,11 +3372,12 @@ function wire() {
// The popup drives the multi-evaluator loop but the service worker
// clears opforRunStatus between evaluators. We persist the popup's
// own queue state so reopening the popup mid-run shows progress.
-async function persistPopupRunQueue() {
+async function persistPopupRunQueue({ running = true, paused = false } = {}) {
try {
await chrome.storage.local.set({
opforPopupRun: {
- running: true,
+ running,
+ paused,
suiteId: state.suiteId,
queue: state.queue,
evIdx: state.evIdx,
@@ -3089,6 +3399,22 @@ async function clearPopupRunQueue() {
}
}
+/**
+ * Most recent completed round, given a run status record and the round
+ * derived by walking its stored transcript.
+ *
+ * The derived value alone undercounts a resumed run: the stored transcript is
+ * capped at the most recent entries, so a run resumed past that cap restarts
+ * its counter. Take the highest of everything the background reports instead.
+ */
+function resolveLastRound(runStatus, derivedRound) {
+ return Math.max(
+ Number(runStatus?.lastRound) || 0,
+ Number(runStatus?.resumedFromRound) || 0,
+ Number(derivedRound) || 0
+ );
+}
+
// ── Live-run recovery from storage ──────────────────────────────
// When the popup opens while a run is in progress, restore the running
// screen and replay the persisted transcript so the user sees what's
@@ -3183,9 +3509,10 @@ async function checkActiveRun() {
lastAssistant = t.content;
}
}
- latestTurn = { round: lastRound, user: lastUser, assistant: lastAssistant };
+ const round = resolveLastRound(opforRunStatus, lastRound);
+ latestTurn = { round, user: lastUser, assistant: lastAssistant };
renderBubbles();
- setTurnProgress(lastRound);
+ setTurnProgress(round);
}
startRunStatusPoller();
@@ -3280,14 +3607,15 @@ function startRunStatusPoller() {
lastAssistant = t.content;
}
}
+ const round = resolveLastRound(opforRunStatus, lastRound);
if (
- lastRound !== latestTurn.round ||
+ round !== latestTurn.round ||
lastUser !== latestTurn.user ||
lastAssistant !== latestTurn.assistant
) {
- latestTurn = { round: lastRound, user: lastUser, assistant: lastAssistant };
+ latestTurn = { round, user: lastUser, assistant: lastAssistant };
renderBubbles();
- setTurnProgress(lastRound);
+ setTurnProgress(round);
}
}
} catch {
diff --git a/runners/extension/service_worker.js b/runners/extension/service_worker.js
index 64114df..5478ae2 100644
--- a/runners/extension/service_worker.js
+++ b/runners/extension/service_worker.js
@@ -71,6 +71,7 @@ function adaptJudgeResult(coreResult) {
function handleMainMessages(message, sendResponse) {
if (message?.type === "OPFOR_UI_STOP") {
state.OPFOR_STOP = true;
+ state.OPFOR_STOP_INTENT = message.intent === "pause" ? "pause" : "cancel";
try {
state.uiRunAbortController?.abort();
} catch {
diff --git a/runners/extension/state.js b/runners/extension/state.js
index 323dc4f..4a48f35 100644
--- a/runners/extension/state.js
+++ b/runners/extension/state.js
@@ -1,6 +1,11 @@
// Shared mutable run state. Use the object reference so all modules see live values.
export const state = {
OPFOR_STOP: false,
+ // "pause" (resumable) or "cancel" (discard) — set from the OPFOR_UI_STOP
+ // message so orchestrator.js knows whether to persist a resumable
+ // snapshot. Defaults to "cancel" so an unspecified/legacy stop never
+ // leaves behind a resumable run the user never asked to keep.
+ OPFOR_STOP_INTENT: "cancel",
uiRunAbortController: null,
retryLocateResolver: null,
};