diff --git a/scripts/gen-wire-fixtures.mjs b/scripts/gen-wire-fixtures.mjs index 88685fcbb..9666433d6 100755 --- a/scripts/gen-wire-fixtures.mjs +++ b/scripts/gen-wire-fixtures.mjs @@ -64,6 +64,7 @@ function controlCases() { return [ { name: "drawing activity", payload: lib.drawingActivityPayload() }, { name: "retry report", payload: lib.retryReportPayload() }, + { name: "skip report", payload: lib.skipReportPayload() }, { name: "thinking start", payload: lib.thinkingPayload(true) }, { name: "thinking end", payload: lib.thinkingPayload(false) }, { name: "yield turn", payload: lib.yieldTurnPayload() }, diff --git a/src/livekit/packets.rs b/src/livekit/packets.rs index 932b434a8..792adfb81 100644 --- a/src/livekit/packets.rs +++ b/src/livekit/packets.rs @@ -468,21 +468,57 @@ pub(super) async fn apply_data_packet( close_turns(room, context); context.transcripts.flush(TRANSCRIPT_FLUSH_LIMIT).await; }; - let (generated, ()) = tokio::join!( - generate_report_bounded( - interview.boot, - &assessment.prompt, - &report_boards, - (assessment.rounds(), assessment.drawing_received()), - api_key, - crate::gemini::GENERATION_SEED, - &assessment.refused, - ), - farewell - ); + let generation = async { + tokio::join!( + generate_report_bounded( + interview.boot, + &assessment.prompt, + &report_boards, + (assessment.rounds(), assessment.drawing_received()), + api_key, + crate::gemini::GENERATION_SEED, + &assessment.refused, + ), + farewell + ) + }; + + // The candidate can give up on the report while it is being written. This + // handler is the only thing reading the room until it returns, so the wait + // is raced against a `skip_report` packet. `biased` so a skip that arrives + // with the report loses nothing: the report is then discarded rather than + // published. Dropping `generation` stops the wait on the model; a request + // already on the wire still finishes server-side, and its answer is never + // read. + let outcome = tokio::select! { + biased; + () = report::wait_for_skip(&mut recovery_events, interview.candidate_identity) => None, + done = generation => Some(done), + }; context.media.audio = None; context.media.video = None; context.media.audio_bytes.clear(); + let Some((generated, ())) = outcome else { + eprintln!( + "candidate skipped the report; discarding it room={}", + interview.boot.room_name + ); + // A stalled ACK must not delay Gemini shutdown indefinitely. + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + publish_control( + room, + &serde_json::json!({"type": "skip_report_ack"}), + "skip report acknowledgement", + ), + ) + .await; + if let Err(error) = context.gemini.shutdown().await { + eprintln!("Gemini close failed ({error}); leaving anyway"); + } + leave_room(room).await; + return Ok(ControlFlow::Break(())); + }; let close_live = async { if let Err(error) = context.gemini.shutdown().await { eprintln!("Gemini close failed ({error}); leaving anyway"); diff --git a/src/livekit/report.rs b/src/livekit/report.rs index 82fa1d756..1e0bbcfe7 100644 --- a/src/livekit/report.rs +++ b/src/livekit/report.rs @@ -395,6 +395,50 @@ fn recovery_request( }) } +/// Whether a packet is the candidate asking to drop the report. Same sender +/// rule as `recovery_request`: only the original candidate may say it, so a +/// bystander in the room cannot cancel somebody else's report. +fn skip_request( + topic: Option<&str>, + sender: Option<&str>, + candidate: &str, + payload: &[u8], +) -> bool { + super::interview_packet(topic, sender, candidate, payload).is_some_and(|(topic, payload)| { + topic == crate::runtime::TOPIC_CONTROL && payload["type"] == "skip_report" + }) +} + +/// Resolves once the candidate asks to skip the report. Raced against the +/// generation, because that wait runs inside the event handler and nothing +/// else reads the room while it does. +/// +/// Every other event is dropped here. The recovery that follows decides +/// presence from the room roster rather than from events it may have missed +/// (see `candidate_present`), and no receipt can be queued before a report +/// has been published. A closed event stream never resolves: the room is gone, +/// and the generation then decides how this ends. +pub(super) async fn wait_for_skip( + events: &mut tokio::sync::mpsc::UnboundedReceiver<::livekit::RoomEvent>, + candidate: &str, +) { + while let Some(event) = events.recv().await { + if let RoomEvent::DataReceived { + topic, + payload, + participant, + .. + } = event + { + let sender = participant.as_ref().map(|p| p.identity().0); + if skip_request(topic.as_deref(), sender.as_deref(), candidate, &payload) { + return; + } + } + } + std::future::pending::<()>().await; +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RecoveryEvent { Retry, diff --git a/tests/browser/dom-contract.test.js b/tests/browser/dom-contract.test.js index 750c4a27d..7ed925531 100644 --- a/tests/browser/dom-contract.test.js +++ b/tests/browser/dom-contract.test.js @@ -1139,3 +1139,231 @@ test("taking the offline summary releases the room", () => { assert.match(show, /state\.room = null/); assert.match(show, /state\.connected = false/); }); + +/// "Skip report and exit": the way out of the report wait that discards the +/// report instead of waiting for it, showing it, or saving it. +/// +/// The button lives on the ending overlay, so it is offered for the whole wait +/// rather than after the escape timer, and nothing about leaving may build the +/// offline summary or touch report history. +test("the ending overlay offers Skip report and exit for the whole wait", () => { + const page = read("interview.html"); + const overlay = page.slice( + page.indexOf('id="ending-overlay"'), + page.indexOf('id="jim-stage"'), + ); + assert.match( + overlay, + /]*id="skip-report"[\s\S]*?Skip report and exit/, + "the skip button is missing from the ending overlay", + ); + + const ending = functionBody(interviewSource(), "endInterview"); + assert.match( + ending, + /nodes\.skipReport\.hidden = !reportComing/, + "the skip is offered as soon as an agent is writing a report", + ); + const escape = ending.slice(ending.search(/REPORT_ESCAPE_WAIT_MS/) - 600); + assert.ok( + !escape.includes("nodes.skipReport"), + "the skip must not wait for the escape timer", + ); +}); + +test("skipping the report tells the agent, ignores any report, and saves nothing", () => { + const script = interviewSource(); + const skip = functionBody(script, "skipReport"); + + assert.match(skip, /window\.confirm\(SKIP_REPORT_CONFIRM\)/); + assert.match( + script, + /End this interview without waiting for a report\? Any report in progress will be discarded and will not be saved\./, + ); + assert.match(skip, /state\.reportSkipped = true/); + assert.match(skip, /publish\(topics\.control, skipReportPayload\(\)\)/); + assert.match(skip, /leaveRoom\(\)/); + // The end must have gone out first, or there is no report to cancel. + assert.ok(skip.indexOf("endSent") < skip.indexOf("skipReportPayload")); + for (const built of ["showReport", "saveHistory", "saveReportHistory"]) { + assert.ok(!skip.includes(built), `skipping must not call ${built}`); + } + + // A report already on its way, and the offline summary, are both refused. + assert.match( + script, + /if \(!acceptsReport\(topic, participant\)\) return;\s*(?:\/\/[^\n]*\n\s*)*if \(state\.reportSkipped\) return;/, + ); + assert.match( + functionBody(script, "showReport"), + /if \(state\.reportSkipped\) return;/, + ); +}); + +test("the skip button is taken away once a report or recovery owns the ways out", () => { + const script = interviewSource(); + assert.match( + script, + /nodes\.leaveRoom\.hidden = true;\s*nodes\.skipReport\.hidden = true;/, + "a report that lands must hide the skip", + ); + assert.match( + functionBody(script, "reportRenderFailed"), + /nodes\.skipReport\.hidden = true/, + ); + assert.match( + script, + /nodes\.skipReport\.hidden = true;\s*nodes\.leaveRoom\.hidden = false;\s*nodes\.leaveRoom\.textContent = "Save incomplete report and leave"/, + ); +}); + +test("late report and every history save entry reject a confirmed skip", () => { + const script = interviewSource(); + assert.match( + functionBody(script, "receiveReport"), + /if \(state\.reportSkipped\) return;/, + "a report delivered after Skip must not be processed", + ); + assert.match( + functionBody(script, "saveHistory"), + /if \(state\.reportSkipped\) return;/, + "recovery or offline saves must not bypass Skip", + ); +}); + +test("the skip packet is the control message the agent listens for", async () => { + const { skipReportPayload } = await import(webUrl("lib.js")); + assert.deepEqual(skipReportPayload(), { type: "skip_report" }); +}); + +test("skip waits for agent acknowledgement before disconnecting", async () => { + const script = interviewSource(); + const { skipReportPayload } = await import(webUrl("lib.js")); + const bodies = ["skipReport", "leaveRoom", "receiveControl"] + .map((name) => `${functionBody(script, name)}\n}`) + .join("\n"); + + function setup() { + const events = []; + const timers = new Map(); + let timerId = 0; + const room = { + disconnect: async () => events.push("disconnect"), + }; + const scope = { + state: { + phase: "ending", + reportReceiving: false, + reportSkipped: false, + room, + }, + nodes: { + skipReport: {}, + forceReport: {}, + leaveRoom: {}, + }, + window: { + confirm: () => true, + location: { href: "/interview" }, + }, + globalThis: { + setTimeout(callback, delay) { + const id = ++timerId; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + }, + topics: { control: "control" }, + skipReportPayload, + endSent: Promise.resolve(), + publish: async () => events.push("publish"), + stopEndingEscape() {}, + stopEndingClock() {}, + stopAvatar() {}, + stopLocalMedia() {}, + beforeUnloadGuard: { dispose() {} }, + reportRecovery: { + stop: () => events.push("stop-recovery"), + finish: () => events.push("finish-recovery"), + }, + }; + + const page = new Function( + "scope", + `with (scope) { + let resolveSkipReportAck = null; + const SKIP_REPORT_SEND_WAIT_MS = 3000; + const SKIP_REPORT_CONFIRM = "Skip report?"; + ${bodies} + return { skipReport, receiveControl }; + }`, + )(new Proxy(scope, { has: (target, key) => key in target })); + + const settle = () => new Promise((resolve) => setImmediate(resolve)); + + return { + ...page, + scope, + events, + timers, + settle, + acknowledge() { + page.receiveControl( + new TextEncoder().encode(JSON.stringify({ type: "skip_report_ack" })), + ); + }, + expire() { + for (const [id, timer] of [...timers]) { + if (timer.delay === 3000) { + timers.delete(id); + timer.callback(); + } + } + }, + }; + } + + // A resolved publish promise does not prove the agent received the packet. + { + const page = setup(); + const leaving = page.skipReport(); + await page.settle(); + assert.ok(page.events.includes("publish")); + assert.ok(!page.events.includes("disconnect")); + assert.equal(page.scope.window.location.href, "/interview"); + + page.acknowledge(); + await leaving; + assert.equal(page.events.filter((e) => e === "disconnect").length, 1); + assert.equal(page.scope.window.location.href, "/"); + } + + // Missing acknowledgements must not trap the candidate indefinitely. + { + const page = setup(); + const leaving = page.skipReport(); + await page.settle(); + assert.ok(!page.events.includes("disconnect")); + page.expire(); + await leaving; + assert.ok(page.events.includes("disconnect")); + assert.equal(page.scope.window.location.href, "/"); + } + + // Recovery arriving during the ACK wait must not finalize a skipped report. + { + const page = setup(); + const leaving = page.skipReport(); + await page.settle(); + page.scope.state.phase = "report_recovery"; + page.acknowledge(); + await leaving; + assert.ok(page.events.includes("stop-recovery")); + assert.ok(!page.events.includes("finish-recovery")); + assert.ok(page.events.includes("disconnect")); + assert.equal(page.scope.window.location.href, "/"); + } +}); diff --git a/tests/browser/report-recovery.test.js b/tests/browser/report-recovery.test.js index b4949d6c1..822514d67 100644 --- a/tests/browser/report-recovery.test.js +++ b/tests/browser/report-recovery.test.js @@ -258,6 +258,7 @@ function pageHarness(phase = "live") { "editor", "ending", "forceReport", + "skipReport", "leaveRoom", "endingDetail", ].map((key) => [key, {}]), diff --git a/tests/fixtures/control.json b/tests/fixtures/control.json index 16fd97edc..5cb61f608 100644 --- a/tests/fixtures/control.json +++ b/tests/fixtures/control.json @@ -13,6 +13,12 @@ "type": "retry_report" } }, + { + "name": "skip report", + "payload": { + "type": "skip_report" + } + }, { "name": "thinking start", "payload": { diff --git a/tests/unit/livekit.rs b/tests/unit/livekit.rs index 7fdcfb265..12d49855f 100644 --- a/tests/unit/livekit.rs +++ b/tests/unit/livekit.rs @@ -1415,7 +1415,10 @@ fn the_ending_leaves_only_after_the_report_delivery_settles() { let publish = ending .find("publish_with_recovery(") .expect("the ending publishes through recovery"); - let leave = ending.find("leave_room(").expect("the ending leaves"); + let leave = ending[publish..] + .find("leave_room(") + .map(|offset| publish + offset) + .expect("the normal report path leaves after publishing"); assert!(publish < leave); assert!(!ending[publish..leave].contains("sleep(")); diff --git a/tests/unit/livekit/report.rs b/tests/unit/livekit/report.rs index 53d10c019..af764f463 100644 --- a/tests/unit/livekit/report.rs +++ b/tests/unit/livekit/report.rs @@ -1087,6 +1087,73 @@ fn only_the_original_candidate_can_request_report_regeneration() { )); } +/// A skip is the candidate's word alone. The packet that cancels a report in +/// progress is checked the way a retry is: right topic, right type, and sent by +/// the original candidate rather than a bystander or an unattributed sender. +#[test] +fn only_the_original_candidate_can_skip_the_report() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../fixtures/control.json")).unwrap(); + let cases = fixture + .as_array() + .unwrap_or_else(|| fixture["cases"].as_array().unwrap()); + let skip = cases + .iter() + .find(|case| case["name"] == "skip report") + .expect("generated skip fixture"); + let payload = serde_json::to_vec(&skip["payload"]).unwrap(); + assert!(skip_request( + Some(crate::runtime::TOPIC_CONTROL), + Some("candidate-original"), + "candidate-original", + &payload + )); + for sender in [None, Some("candidate-other"), Some("interviewer-room")] { + assert!(!skip_request( + Some(crate::runtime::TOPIC_CONTROL), + sender, + "candidate-original", + &payload + )); + } + assert!(!skip_request( + Some(crate::runtime::TOPIC_CODE_UPDATE), + Some("candidate-original"), + "candidate-original", + &payload + )); + assert!(!skip_request( + None, + Some("candidate-original"), + "candidate-original", + &payload + )); + assert!(!skip_request( + Some(crate::runtime::TOPIC_CONTROL), + Some("candidate-original"), + "candidate-original", + b"not JSON" + )); + + // The other control packets this path sees are not a skip, and a skip is + // not a retry: the recovery wait must not treat it as one. + for other in ["retry_report", "end_interview", "report_received"] { + let other = serde_json::to_vec(&serde_json::json!({ "type": other })).unwrap(); + assert!(!skip_request( + Some(crate::runtime::TOPIC_CONTROL), + Some("candidate-original"), + "candidate-original", + &other + )); + } + assert!(!recovery_request( + Some(crate::runtime::TOPIC_CONTROL), + Some("candidate-original"), + "candidate-original", + &payload + )); +} + #[tokio::test] async fn only_a_failed_report_can_offer_regeneration() { let keys = GeminiKeys::single("test"); @@ -1549,6 +1616,35 @@ fn recovery_offer_matches_browser_limits_and_generation_deadline() { ); } +/// An unattributed skip must not finish the wait. Closing the event +/// stream must not make it finish either. +#[tokio::test] +async fn skip_wait_does_not_finish_without_candidate_authorization() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + tx.send(::livekit::RoomEvent::Reconnecting).unwrap(); + tx.send(::livekit::RoomEvent::DataReceived { + payload: std::sync::Arc::new(br#"{"type":"skip_report"}"#.to_vec()), + topic: Some(crate::runtime::TOPIC_CONTROL.into()), + kind: ::livekit::DataPacketKind::Reliable, + participant: None, + }) + .unwrap(); + + drop(tx); + + let result = tokio::time::timeout( + std::time::Duration::from_millis(50), + wait_for_skip(&mut rx, "candidate-original"), + ) + .await; + + assert!( + result.is_err(), + "an unattributed skip or closed stream must not cancel the report" + ); +} + #[tokio::test] async fn recovery_room_events_ignore_unrelated_activity_and_stop_on_disconnect() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/web/interview.html b/web/interview.html index dc53875af..8082985f7 100644 --- a/web/interview.html +++ b/web/interview.html @@ -819,6 +819,13 @@

+ + diff --git a/web/interview.js b/web/interview.js index b9d98adc9..a640ddf8a 100644 --- a/web/interview.js +++ b/web/interview.js @@ -95,6 +95,7 @@ import { endInterviewPayload, drawingActivityPayload, retryReportPayload, + skipReportPayload, escapeHtml, formatTime, integrityEventPayload, @@ -508,6 +509,7 @@ const state = { categoryComparisonReady: Promise.resolve(), downloadingReport: false, reportReceiving: false, + reportSkipped: false, /// Set when the interviewer's report reached this page and could not be /// rendered. The offline summary that follows is written from what this page /// holds either way; what this changes is the sentence explaining why the @@ -638,6 +640,7 @@ const nodes = { endingTitle: document.querySelector("#ending-title"), endingWithdrawConsent: document.querySelector("#ending-withdraw-consent"), retryReport: document.querySelector("#retry-report"), + skipReport: document.querySelector("#skip-report"), forceReport: document.querySelector("#force-report"), leaveRoom: document.querySelector("#leave-room"), endingDetail: document.querySelector("#ending-detail"), @@ -1104,6 +1107,7 @@ function bindEvents() { withdrawRecordingConsent, ); nodes.retryReport.addEventListener("click", () => reportRecovery.retry()); + nodes.skipReport.addEventListener("click", () => void skipReport()); nodes.forceReport.addEventListener("click", showReport); nodes.leaveRoom.addEventListener("click", leaveRoom); nodes.run.addEventListener("click", runTests); @@ -2276,6 +2280,9 @@ async function connectLiveKit(connection, preflight, presenting = false) { return; } if (!acceptsReport(topic, participant)) return; + // The candidate chose to leave without a report. One that was already on + // its way is dropped unread, so it is neither drawn nor saved. + if (state.reportSkipped) return; // The report has arrived. Nothing may offer the offline summary or a way // out while it is drawn, or the candidate could save an unscored summary // beside it; `reportRenderFailed` gives the ways out back. Only the copy @@ -2287,6 +2294,7 @@ async function connectLiveKit(connection, preflight, presenting = false) { nodes.end.disabled = true; nodes.forceReport.hidden = true; nodes.leaveRoom.hidden = true; + nodes.skipReport.hidden = true; stopEndingEscape(); } await receiveReportDelivery( @@ -2559,6 +2567,9 @@ const reportRecovery = createReportRecovery({ : "Evaluation temporarily unavailable"; nodes.ending.querySelector(".spinner").hidden = true; nodes.forceReport.hidden = true; + // Recovery owns the ways out from here; the agent is no longer writing a + // report that a skip could stop. + nodes.skipReport.hidden = true; nodes.leaveRoom.hidden = false; nodes.leaveRoom.textContent = "Save incomplete report and leave"; nodes.retryReport.hidden = false; @@ -2637,6 +2648,8 @@ window.addEventListener("pagehide", finalizeRecoveryOnPageHide); /// room stays up until then so the agent can stop retransmitting. Recovery's /// own finalize has no receipt to wait for. async function receiveReport(room, payload, flushed = Promise.resolve()) { + // A late packet must never restart report handling after Skip. + if (state.reportSkipped) return; // The wait ended when this packet arrived, whatever becomes of it below. stopEndingEscape(); let rendered = false; @@ -3216,7 +3229,9 @@ function showFrameworkHint() { function receiveControl(bytes) { try { const message = JSON.parse(new TextDecoder().decode(bytes)); - if (message.type === "report_retry") { + if (message.type === "skip_report_ack") { + resolveSkipReportAck?.(); + } else if (message.type === "report_retry") { reportRecovery.notice(message); } else if ( message.type === "thinking_state" && @@ -3961,6 +3976,12 @@ const REPORT_ESCAPE_WAIT_MS = 155000; /// "preparing your report" and then with an offer to retry the provider. let endingEscape = []; +/// Settles once `end_interview` has been sent. A skip must follow it: sent +/// first, the agent would have no report in progress to cancel and would write +/// one for a page that has already left. The whiteboard and example drawing +/// delay the end until their uploads settle, so this can lag the click. +let endSent = Promise.resolve(); + /// A final packet can arrive after the participant's departure notification, /// particularly from an older agent that leaves without waiting for a receipt. /// Offering the offline summary immediately lets the candidate disconnect @@ -4041,6 +4062,9 @@ function endInterview(reason) { roomInterviewer(roomParticipants(), state.agentIdentity), ); nodes.forceReport.hidden = reportComing; + // Only while an agent is writing a report there is something to skip; with + // none in the room the offline summary follows within a moment instead. + nodes.skipReport.hidden = !reportComing; startEndingClock(); if (reportComing) { // The wait clears report generation, the timer-driven wrap-up, the Gemini @@ -4075,11 +4099,11 @@ function endInterview(reason) { endInterviewPayload(reason, currentCode(), state.language), ); if (whiteboard) { - void boardUploadsSettled().then(publishEnd); + endSent = boardUploadsSettled().then(publishEnd); } else if (exampleFinished) { - void exampleFinished.then(publishEnd); + endSent = exampleFinished.then(publishEnd); } else { - publishEnd(); + endSent = Promise.resolve(publishEnd()); } if (!reportComing) setTimeout(showReport, 300); } @@ -4107,10 +4131,64 @@ function stopEndingClock() { nodes.endingElapsed.textContent = ""; } +const SKIP_REPORT_CONFIRM = + "End this interview without waiting for a report? Any report in progress will be discarded and will not be saved."; + +/// Wait at most this long for the agent to acknowledge an accepted Skip. +/// If the acknowledgement never arrives, leave anyway: cancellation is +/// best-effort, and a missing packet must not trap the candidate. +const SKIP_REPORT_SEND_WAIT_MS = 3000; +let resolveSkipReportAck = null; + +/// "Skip report and exit": the candidate leaves without waiting for, seeing, +/// or saving a report. Tells the agent to drop the one it is writing, then +/// leaves exactly as `leaveRoom` does. Nothing is built from what this page +/// holds, so no offline summary and no history entry exist to be saved. +async function skipReport() { + if (state.phase !== "ending" || state.reportReceiving) return; + if (!window.confirm(SKIP_REPORT_CONFIRM)) return; + // The report may have landed while the dialog was open. Taking a report the + // candidate is already reading away would be the wrong answer to a click + // made about a wait. + if (state.phase !== "ending" || state.reportReceiving) return; + state.reportSkipped = true; + nodes.skipReport.disabled = true; + nodes.forceReport.hidden = true; + nodes.leaveRoom.hidden = true; + stopEndingEscape(); + let acknowledge; + const acknowledged = new Promise((resolve) => { + acknowledge = resolve; + }); + resolveSkipReportAck = acknowledge; + + void Promise.resolve(endSent) + .then(() => publish(topics.control, skipReportPayload())) + .catch(() => {}); + + let timeout; + try { + await Promise.race([ + acknowledged, + new Promise((resolve) => { + timeout = globalThis.setTimeout(resolve, SKIP_REPORT_SEND_WAIT_MS); + }), + ]); + } finally { + globalThis.clearTimeout(timeout); + resolveSkipReportAck = null; + } + leaveRoom(); +} + function leaveRoom() { if (state.phase === "report_recovery") { - reportRecovery.finish(); - return; + if (state.reportSkipped) { + reportRecovery.stop(); + } else { + reportRecovery.finish(); + return; + } } if (state.reportReceiving) return; if ( @@ -4132,6 +4210,9 @@ async function showReport() { // An arrived report outranks the offline summary, including from the timer // `endInterview` sets when no report is expected. if (state.phase === "report" || state.reportReceiving) return; + // Leaving without a report: the offline summary is a report too, and it is + // saved to history the same way. + if (state.reportSkipped) return; state.phase = "report"; // The offline summary is now offered while a room is still up -- it used to // be hidden for the whole life of one -- so this is the one report path that @@ -4220,6 +4301,7 @@ function reportRenderFailed(error, { offlineSummary }) { ).message; nodes.leaveRoom.hidden = false; nodes.forceReport.hidden = !offlineSummary; + nodes.skipReport.hidden = true; } function renderReport() { @@ -4363,6 +4445,8 @@ function unclearLines() { } async function saveHistory(report = state.report) { + // Covers normal delivery, offline summary, and recovery persistence. + if (state.reportSkipped) return; // The interview id travels with the report so the replay page can put the // two beside each other. Reports are keyed by their own id and recordings by // theirs, and without this the only thing relating them is the clock. diff --git a/web/lib.js b/web/lib.js index c8b04f5f9..d6de62d1c 100644 --- a/web/lib.js +++ b/web/lib.js @@ -2149,3 +2149,9 @@ export function isYieldShortcut(event, editor) { export function retryReportPayload() { return { type: "retry_report" }; } + +/// Tells the agent to stop writing the report: the candidate chose to leave +/// without one. The agent drops whatever it generated and publishes nothing. +export function skipReportPayload() { + return { type: "skip_report" }; +}