From ad4c93e2ed7d9c20a9d2fc0f40cbc725809961a4 Mon Sep 17 00:00:00 2001 From: chien1120yc Date: Mon, 26 Oct 2026 15:39:08 +0800 Subject: [PATCH] Allow skipping report generation when ending Add a confirmed skip action to the ending overlay so candidates can discard a report during generation and return to the lobby. Handle cancellation in the agent, guard late report handling, and cover the behavior with Rust and browser tests. Close #100 --- docs/provider-cost-and-degradation.md | 4 + scripts/gen-wire-fixtures.mjs | 1 + src/livekit/packets.rs | 60 +++++-- src/livekit/report.rs | 51 ++++++ tests/browser/dom-contract.test.js | 247 ++++++++++++++++++++++++++ tests/browser/report-recovery.test.js | 1 + tests/fixtures/README.md | 2 +- tests/fixtures/control.json | 6 + tests/unit/livekit.rs | 35 +++- tests/unit/livekit/report.rs | 109 ++++++++++++ web/interview.html | 7 + web/interview.js | 104 ++++++++++- web/lib.js | 6 + 13 files changed, 613 insertions(+), 20 deletions(-) diff --git a/docs/provider-cost-and-degradation.md b/docs/provider-cost-and-degradation.md index faa5a9376..b6fb4901c 100644 --- a/docs/provider-cost-and-degradation.md +++ b/docs/provider-cost-and-degradation.md @@ -799,3 +799,7 @@ does an actual transport close. This does not promise lossless recovery from an unavailable provider. Microphone energy is measured during provider activity even after the first transcription, without letting that energy suppress silence nudges indefinitely. + +### Skipping the evaluation report + +When the agent receives and accepts a candidate's `skip_report` request, it discards the evaluation report and sends `skip_report_ack` before leaving the room. Skipping does not undo the cost of Gemini requests already in flight, which may still be billed. A successfully skipped interview produces no evaluation report or new report-history entry; existing recordings and recording consent are unaffected. 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..dce755e09 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, + &report::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..93c99b08a 100644 --- a/src/livekit/report.rs +++ b/src/livekit/report.rs @@ -395,6 +395,57 @@ 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" + }) +} + +/// What the agent tells the page once it has dropped the report. The page +/// leaves on this and on nothing else, so it is the only signal that Skip took +/// effect; `web/interview.js` matches the same `type`. +pub(super) fn skip_report_ack() -> serde_json::Value { + serde_json::json!({ "type": "skip_report_ack" }) +} + +/// 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..5eea1c83b 100644 --- a/tests/browser/dom-contract.test.js +++ b/tests/browser/dom-contract.test.js @@ -1139,3 +1139,250 @@ 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("confirming Skip changes the overlay and keeps focus off the page body", () => { + const skip = functionBody(interviewSource(), "skipReport"); + // Focus moves before the focused button is disabled, or it drops to . + assert.ok( + skip.indexOf("nodes.endingTitle.focus()") < + skip.indexOf("nodes.skipReport.disabled = true"), + "focus must leave the Skip button before it is disabled", + ); + assert.match( + skip, + /nodes\.endingTitle\.textContent = "Skipping report\.\.\."/, + ); + assert.match(skip, /nodes\.endingDetail\.textContent =/); + assert.match(skip, /querySelector\("\.spinner"\)\.hidden = true/); +}); + +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: {}, + endingTitle: { focus() {} }, + endingDetail: {}, + ending: { querySelector: () => ({}) }, + }, + 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/README.md b/tests/fixtures/README.md index 7564572ec..b8d7b4ee8 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -14,7 +14,7 @@ only thing in the repo that crosses that boundary. |---|---|---| | `code-update.json` | `codeUpdatePayload` | `apply_code_update` | | `report-recovery.json` | `reportRecoveryLimits` in `web/report-recovery.js` | `recovery_metadata`, `recovery_notice`, `REPORT_TIMEOUT` | -| `control.json` | `drawingActivityPayload`, `thinkingPayload`, `yieldTurnPayload`, `timeWarningPayload`, `endInterviewPayload`, `retryReportPayload` | `apply_control`, `recovery_request` | +| `control.json` | `drawingActivityPayload`, `thinkingPayload`, `yieldTurnPayload`, `timeWarningPayload`, `endInterviewPayload`, `retryReportPayload`, `skipReportPayload` | `apply_control`, `recovery_request`, `skip_request` | | `test-results.json` | `testPayload` | `apply_test_results` | | `integrity-chain.json` | `integrityEventPayload` | `apply_integrity` | 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..e4a896f2e 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(")); @@ -1434,6 +1437,36 @@ fn the_ending_leaves_only_after_the_report_delivery_settles() { assert!(publish.contains("deliver_report(")); } +/// A skipped report is acknowledged before the agent leaves. The page waits +/// for that packet before it disconnects, so one published after `leave_room`, +/// or dropped from this branch, sends every Skip to the page's timeout. +#[test] +fn a_skip_is_acknowledged_before_the_agent_leaves() { + let source = include_str!("../../src/livekit/packets.rs"); + let ending = source + .split("async fn apply_data_packet(") + .nth(1) + .expect("apply_data_packet is still defined here") + .split("\n}\n") + .next() + .unwrap_or_default(); + let skipped = ending + .split("let Some((generated, ())) = outcome else {") + .nth(1) + .expect("the skip branch is still where generation ends") + .split("\n };\n") + .next() + .unwrap_or_default(); + let ack = skipped + .find("skip_report_ack()") + .expect("the skip branch publishes the acknowledgement"); + let leave = skipped + .find("leave_room(") + .expect("the skip branch leaves the room"); + assert!(skipped.contains("publish_control(")); + assert!(ack < leave); +} + /// Each pause reads the stretch since the last one, and never that stretch /// twice. /// diff --git a/tests/unit/livekit/report.rs b/tests/unit/livekit/report.rs index 53d10c019..f326926c8 100644 --- a/tests/unit/livekit/report.rs +++ b/tests/unit/livekit/report.rs @@ -1087,6 +1087,86 @@ fn only_the_original_candidate_can_request_report_regeneration() { )); } +/// The page leaves on this packet alone, so its spelling is the contract. A +/// rename here that the page does not follow would send every Skip to the +/// timeout while both suites stayed green; the page is read for the same type. +#[test] +fn the_skip_acknowledgement_is_the_packet_the_page_waits_for() { + assert_eq!( + skip_report_ack(), + serde_json::json!({ "type": "skip_report_ack" }) + ); + let page = include_str!("../../../web/interview.js"); + assert!(page.contains("message.type === \"skip_report_ack\"")); +} + +/// 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 +1629,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 4ca0cc90c..f3072b10e 100644 --- a/web/interview.js +++ b/web/interview.js @@ -98,6 +98,7 @@ import { endInterviewPayload, drawingActivityPayload, retryReportPayload, + skipReportPayload, escapeHtml, formatTime, integrityEventPayload, @@ -514,6 +515,7 @@ const state = { downloadingReport: false, reportSaveComplete: 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 @@ -644,6 +646,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"), @@ -1110,6 +1113,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); @@ -2287,6 +2291,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 @@ -2298,6 +2305,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( @@ -2570,6 +2578,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; @@ -2648,6 +2659,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; @@ -3227,7 +3240,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" && @@ -4031,6 +4046,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 @@ -4111,6 +4132,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 @@ -4145,11 +4169,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); } @@ -4177,11 +4201,73 @@ 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; + // The overlay must show that Skip took effect, or it keeps saying a report + // is being written. Focus moves to the title first: disabling the button + // that has it would drop focus to the page body. + nodes.endingTitle.focus(); + nodes.skipReport.disabled = true; + nodes.endingTitle.textContent = "Skipping report..."; + nodes.endingDetail.textContent = + "Letting the interviewer know. This can take a few seconds."; + nodes.ending.querySelector(".spinner").hidden = 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.downloadingReport) return; if (state.phase === "report_recovery") { - reportRecovery.finish(); - return; + if (state.reportSkipped) { + reportRecovery.stop(); + } else { + reportRecovery.finish(); + return; + } } if (state.reportReceiving) return; if ( @@ -4203,6 +4289,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 @@ -4291,6 +4380,7 @@ function reportRenderFailed(error, { offlineSummary }) { ).message; nodes.leaveRoom.hidden = false; nodes.forceReport.hidden = !offlineSummary; + nodes.skipReport.hidden = true; } function renderReport() { @@ -4438,6 +4528,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 7de15f8b0..b0f29835b 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" }; +}