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
1 change: 1 addition & 0 deletions scripts/gen-wire-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
60 changes: 48 additions & 12 deletions src/livekit/packets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
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
228 changes: 228 additions & 0 deletions tests/browser/dom-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/<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" });
});

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, "/");
}
});
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
6 changes: 6 additions & 0 deletions tests/fixtures/control.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
"type": "retry_report"
}
},
{
"name": "skip report",
"payload": {
"type": "skip_report"
}
},
{
"name": "thinking start",
"payload": {
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/livekit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("));

Expand Down
Loading