Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions src/livekit/packets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,21 +468,47 @@ 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
);
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");
Expand Down
44 changes: 44 additions & 0 deletions src/livekit/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions tests/browser/dom-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1136,3 +1136,99 @@ 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,
/<button[^>]*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" });
});
1 change: 1 addition & 0 deletions tests/browser/report-recovery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ function pageHarness(phase = "live") {
"editor",
"ending",
"forceReport",
"skipReport",
"leaveRoom",
"endingDetail",
].map((key) => [key, {}]),
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/livekit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1362,7 +1362,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("));

Expand Down
87 changes: 87 additions & 0 deletions tests/unit/livekit/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,64 @@ 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 payload = serde_json::to_vec(&serde_json::json!({ "type": "skip_report" })).unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test builds the skip_report packet by hand, unlike the retry test above it, which takes its packet from tests/fixtures/control.json. Nothing ties skipReportPayload() in web/lib.js to what skip_request accepts, so a renamed type on either side passes both suites. Add a { name: "skip report", payload: lib.skipReportPayload() } case to controlCases() in scripts/gen-wire-fixtures.mjs, regenerate the fixture, and read the payload from it here the way only_the_original_candidate_can_request_report_regeneration does.

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");
Expand Down Expand Up @@ -1549,6 +1607,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();
Expand Down
7 changes: 7 additions & 0 deletions web/interview.html
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,13 @@ <h3 id="devices-title" class="preflight-section-title">
<button id="force-report" class="pill-button" type="button">
Show offline summary
</button>
<!-- Offered for the whole wait, unlike the two above: a candidate who
does not want the report should not have to sit out the timer to
leave. Hidden by interview.js once a report arrives or recovery
takes over the ways out. -->
<button id="skip-report" class="pill-button" type="button" hidden>
Skip report and exit
</button>
<!-- The sidebar's Stop recording is behind this overlay, and the
recording runs until the report arrives. interview.js keeps this
one in step with it. -->
Expand Down
Loading