diff --git a/README.md b/README.md
index 0465e604..59326b48 100644
--- a/README.md
+++ b/README.md
@@ -106,7 +106,7 @@ INTERVIEW_ROOM_NAME=interview-local make web
```
Open , choose a problem and duration, then complete the
-media preflight. `INTERVIEW_ROOM_NAME` pins every interview to that one room
+media preflight. `INTERVIEW_ROOM_NAME` pins Practice interviews to that one room
name, which is what a local run wanting a stable URL usually wants; without it
each session gets a random room. The pin is dropped for a session if its LiveKit
project has run out of connection minutes, so a spent project costs the stable
@@ -130,6 +130,15 @@ cannot be run cannot complete the REACTO Test step.
## Interview behavior
+Practice is the default: choose a problem, reopen its URL or reload to practice
+it again after media preflight. Strict Interview requires sign-in, keeps the
+difficulty choice and assigns a matching problem on the server only after preflight. Reloading
+returns to preflight, then starts a fresh room with a different matching problem
+from this tab's previous assignment, or refuses if none remains. It ignores URL
+problem selection and restores no interview progress. Neither mode treats a
+refresh as cheating. See [interview selection](docs/interview-selection.md) for
+tab behavior, storage requirements and the selection boundary.
+
Jim asks short, targeted questions between logical blocks, offers progressive
conceptual hints, and uses the latest test run in the final assessment. Voice
responses stop when the candidate interrupts. Say "can I get a hint?" when
diff --git a/docs/development.md b/docs/development.md
index 219ecf97..8ce5f8f9 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -5,6 +5,11 @@ time actually goes. `make` targets and the one-line summary of each are in
[README.md](../README.md#development); everything here is the detail behind
them.
+Selection admission and refresh behavior are specified in
+[interview selection](interview-selection.md). The Strict browser regression tests use
+the existing real media preflight with a fake media grant and room transport;
+they do not exercise paid LiveKit, Gemini or Egress services.
+
## Dependency security checks
A new RustSec advisory must reach maintainers without making an unrelated
diff --git a/docs/interview-selection.md b/docs/interview-selection.md
new file mode 100644
index 00000000..f8d7416b
--- /dev/null
+++ b/docs/interview-selection.md
@@ -0,0 +1,148 @@
+# Interview selection
+
+Practice is the default. Its problem picker, published-id and scenario URLs,
+repeat attempts, refresh behavior, offline fallback and local candidate cases
+are unchanged. Reloading a Practice URL runs media preflight and starts the same
+problem again; it does not recover the previous attempt.
+
+Strict Interview is an opt-in selection policy, separate from the coding or
+whiteboard surface and the coding-only or behavioral loop. Difficulty remains
+a multi-select choice in both modes. Strict hides the random, saved-list and
+specific-problem pickers; the server draws from the selected levels, without
+using Practice's history recommendations or topic filter. The lobby sends `selection=strict`
+and one `difficulty` query parameter per checked level, without a problem. A
+Strict URL's `problem` parameter is ignored; reopening it cannot request that
+problem. Switching to Practice is allowed. This is a self-assessment mode, not
+an administrator-enforced exam.
+
+## Admission and selection
+
+Strict requires a signed-in account even when the deployment allows guests.
+Practice retains guest admission, its separate rate limit and local report
+history. The lobby keeps Strict Start disabled until the account is signed in;
+its existing GitHub sign-in control remains available. The server refuses guest
+Strict requests with HTTP 401 and
+`strict_login_required` before selecting a problem, reserving a room or
+dispatching an agent. No guest receipt format or anonymous identity is added.
+
+The recording-consent notice appears during media preflight; the camera
+and notice are required on recording deployments. Consent is recorded and a
+room requested only after preflight succeeds; failure or leaving for the lobby creates no attempt.
+
+`POST /api/token` accepts `selectionMode: "strict"`, optional `difficulties`
+and an optional `previousAssignment` receipt. The browser passes the URL's
+`difficulty` values as the `difficulties` array without dropping invalid values.
+When present, it must contain one to three distinct strings drawn from exactly
+`"Easy"`, `"Medium"` and `"Hard"`. Empty arrays, duplicates, unknown values,
+wrong casing and non-string values are refused with HTTP 400 and
+`strict_difficulty_invalid`. Missing difficulties keep the all-level draw for
+existing Strict URLs and API clients; this is not a fallback for invalid input.
+
+The endpoint rejects any `problemId` field in a Strict request, including null,
+even with valid difficulties. Missing selection mode and explicit `"practice"`
+keep the existing API. Unknown modes are refused.
+
+After the existing account and consent checks, the server randomly selects
+from the checked-in problems matching any selected difficulty, excluding only
+the preceding assignment. The response adds `problemPage`, the public scenario
+page named in the signed LiveKit metadata, and a new `previousAssignment`
+receipt. It uses a fresh room
+and candidate identity even when `INTERVIEW_ROOM_NAME` pins Practice to a
+development room. The normal provider quota, capacity, consent claim and
+dispatcher gates still apply. The hosting dispatcher starts an independent
+agent; a web-only deployment must staff these dynamically named rooms through
+its external agent host, just as for unpinned Practice rooms. An agent launched
+manually for the fixed room does not serve Strict attempts.
+
+If no eligible problem remains after difficulty filtering and exclusion,
+admission is refused with HTTP 409 and `strict_no_alternative`, before a room
+or agent is allocated. The browser shows the reason and keeps its previous
+receipt. There is no fallback to another level or to the previous problem.
+A pool with one matching problem allows it once, then refuses a request
+carrying that assignment. A later return to an older problem is allowed (A, B, A).
+
+The selected difficulties stay in the interview URL across a normal reload.
+The receipt format is unchanged: it names the previous problem regardless of
+difficulty. If a caller changes the difficulty filter, that page is still
+excluded whenever it belongs to the new pool.
+
+Try again always selects Practice, including for a report from a Strict attempt.
+It keeps the original problem, a valid saved coding language and the existing
+duration, loop and surface settings. Switching to Strict clears the retry
+language; its lobby URL contains neither that preference nor a specific problem.
+
+A direct Strict URL may request a coding language with `language`, just as in
+Practice. Python is the default when no supported language is requested. After
+assignment, language availability follows the assigned problem and its judge.
+A delayed judge may restore the requested language only before the candidate
+chooses a language, edits or runs code, or ends the interview.
+
+## The previous-assignment receipt
+
+Only one receipt is kept in this tab's `sessionStorage`. It contains a purpose
+and format version, account id and public scenario page, authenticated with the
+server's session signing secret. It conveys neither room access nor recording
+consent and contains no interview content. The server verifies the signature,
+account and current scenario before excluding the page. Invalid receipts are
+refused; another account or a rotated signing secret requires a new sequence.
+
+The browser checks storage before allocation and saves the new receipt before
+loading or revealing the assigned problem or joining the room. A token refusal
+keeps the old receipt; a subsequent problem-load or join failure keeps the new
+one. Storage failure, an invalid assignment or a missing problem stops Strict
+admission without starting offline practice or falling back to the default
+problem. Retry returns to preflight. Strict candidate cases stay in memory.
+
+Strict connection failures show the failure reason and a Not connected agent
+label, without offering offline practice. The token request has a 20-second
+browser deadline, including its response body. A timeout offers Back to lobby
+or Retry media preflight; retry keeps the receipt and requires preflight and
+consent again. Practice keeps its existing offline fallback.
+
+Reloading retains this tab's receipt and returns to preflight. A browser history
+restore from the back/forward cache reloads Strict through the same gate only
+when the existing unload policy allows leaving. A started attempt without a
+saved report stays on the restored page, preserving its content and warning.
+Opening an old URL in a new tab has no prior assignment unless the browser
+copied the opener's session storage. Independent tabs have independent sequences; a duplicated tab
+initially excludes the copied assignment, then progresses independently. Two
+tabs may receive the same new problem. Explicitly starting Strict from the lobby
+clears this tab's sequence; returning to the lobby alone does not.
+
+## Limits
+
+Strict is an opt-in self-assessment mode. Excluding only the previous problem
+does not prevent repeated refreshes from eventually drawing a familiar one.
+It keeps the existing agent cleanup policy, so an old agent may temporarily
+occupy capacity while a new attempt starts. This change adds neither
+multi-problem history nor Strict-specific cleanup.
+
+The receipt is not an account-wide attempt ledger or a replay-proof credential.
+Clearing storage, omitting the receipt or replaying an older valid receipt can
+reset the exclusion. The server still selects the next problem and
+never accepts a caller-selected Strict problem. Enforcing the latest attempt
+across tabs or devices would need server-side history and a separate product
+decision. There is no cheating signal, score penalty or permanent exclusion.
+
+## Lifecycle and contracts
+
+A browser reload starts a new attempt. It restores no editor, transcript,
+timer, framework progress, evidence, report-recovery inputs or Gemini session.
+The previous room follows its existing participant-absence cleanup and recording
+lifecycle; it may briefly overlap the new attempt and consume a capacity slot.
+
+LiveKit reconnect within a loaded page keeps its current room and problem.
+Gemini reconnect and same-agent report recovery remain unchanged. The existing
+beforeunload warning still protects an active, unsaved interview in both modes;
+it does not classify refresh or trigger allocation.
+
+Recording consent is still claimed once per room, and starting again requires
+fresh consent. Report shape, assessment prompts, rubric and integrity evidence
+semantics do not change, so no interview contract bundle or component version
+is bumped. The receipt's format tag versions only selection metadata.
+
+Regression coverage lives in `tests/unit/web/selection.rs`,
+`tests/web/token.rs` and `tests/browser/strict-selection.test.js`. The
+credential-free browser tests use the real preflight and page reload with a
+fake media grant and room transport; provider reconnect and recording still
+need the existing credentialed integration checks for end-to-end assurance.
diff --git a/docs/provider-cost-and-degradation.md b/docs/provider-cost-and-degradation.md
index 41172b48..3dd8dbcd 100644
--- a/docs/provider-cost-and-degradation.md
+++ b/docs/provider-cost-and-degradation.md
@@ -287,9 +287,15 @@ keys, prompts, transcripts, code, or grounding text.
## What may be cached
-Checked-in and static problem-bank data, schemas, and vendored runtime metadata,
-and nothing else. Never candidate prompts, transcript, code, profile, job
-description or resume grounding, provider output, repair output, framework
+Checked-in and static problem-bank data, schemas, and vendored runtime metadata
+may be cached. The one selection-only exception is a single server-signed
+previous-assignment receipt in tab-scoped session storage for
+[Strict Interview](interview-selection.md): purpose/version, account id and
+public scenario page only, with no room credential or interview content. It
+excludes the immediately preceding problem on a new attempt and is never an
+input to assessment or session recovery.
+
+Never cache candidate prompts, transcript, code, profile, job description or resume grounding, provider output, repair output, framework
evidence, or personalized feedback. Report requests are built from the current
session and each retry uses that same session's immutable prompt, so there is no
cross-session response cache. A failed report may keep its frozen prompt and
diff --git a/src/web/mod.rs b/src/web/mod.rs
index d519cfee..46692526 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -24,6 +24,7 @@ mod interviews;
mod policy;
mod pool;
mod recordings;
+mod selection;
mod setup;
mod token;
diff --git a/src/web/pool.rs b/src/web/pool.rs
index d0b0c4d4..d9f8baa6 100644
--- a/src/web/pool.rs
+++ b/src/web/pool.rs
@@ -246,7 +246,12 @@ pub(crate) enum ProviderChoice<'a> {
///
/// The counter advances per attempt, not per request, so a dead project does
/// not pin every later interview to the one after it.
-pub(crate) async fn room_and_available_provider(state: &AppState) -> ProviderChoice<'_> {
+/// `require_fresh_room` bypasses the pinned development room so a new attempt
+/// cannot reuse its existing interviewer and evidence.
+pub(crate) async fn room_and_available_provider(
+ state: &AppState,
+ require_fresh_room: bool,
+) -> ProviderChoice<'_> {
// A pinned development room names the project it belongs to, so it is
// preferred while that project can still take a connection, and only while.
// A pin whose project is out of minutes is a room nobody can join: the
@@ -258,7 +263,8 @@ pub(crate) async fn room_and_available_provider(state: &AppState) -> ProviderCho
// the pin is the room name, so nothing here rotates and no counter is spent
// on it. Spending one anyway would advance the rotation on every pinned
// request, which is the opposite of what a pin is for.
- if !state.config.production
+ if !require_fresh_room
+ && !state.config.production
&& let Some(room_name) = state
.config
.fixed_room_name
diff --git a/src/web/selection.rs b/src/web/selection.rs
new file mode 100644
index 00000000..6ce55263
--- /dev/null
+++ b/src/web/selection.rs
@@ -0,0 +1,211 @@
+//! Strict selection changes admission, never the interviewer or its assessment.
+//! The receipt remembers only this tab's previous assignment. It grants no
+//! room access and is not proof that the browser kept its newest receipt.
+
+use axum::http::StatusCode;
+use axum::response::Response;
+use ring::rand::SecureRandom;
+use serde_json::{Value, json};
+
+use crate::agent::{PROBLEMS, Problem, find_problem};
+use crate::token::{sign_hs256, verify_hs256};
+
+use super::json_response;
+
+const PURPOSE: &str = "strict-selection-v1";
+const DIFFICULTIES: &[&str] = &["Easy", "Medium", "Hard"];
+
+pub(super) struct Assignment {
+ pub page: &'static str,
+ pub receipt: String,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) enum SelectionError {
+ Mode,
+ Problem,
+ Difficulty,
+ Receipt,
+ NoAlternative,
+ Random,
+}
+
+impl SelectionError {
+ pub(super) fn response(self) -> Response {
+ let (status, code, error) = match self {
+ Self::Mode => (
+ StatusCode::BAD_REQUEST,
+ "selection_mode_invalid",
+ "Choose Practice or Strict Interview.",
+ ),
+ Self::Problem => (
+ StatusCode::BAD_REQUEST,
+ "strict_problem_forbidden",
+ "Strict Interview assigns its problem on the server.",
+ ),
+ Self::Difficulty => (
+ StatusCode::BAD_REQUEST,
+ "strict_difficulty_invalid",
+ "Choose one or more distinct difficulties: Easy, Medium or Hard.",
+ ),
+ Self::Receipt => (
+ StatusCode::BAD_REQUEST,
+ "strict_assignment_invalid",
+ "The previous Strict assignment could not be verified. Return to the lobby to start a new selection sequence.",
+ ),
+ Self::NoAlternative => (
+ StatusCode::CONFLICT,
+ "strict_no_alternative",
+ "No different problem is available at the selected difficulties. Return to the lobby to change the selection.",
+ ),
+ Self::Random => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ "strict_selection_unavailable",
+ "A problem could not be assigned. Try again later.",
+ ),
+ };
+ json_response(status, json!({ "code": code, "error": error }))
+ }
+}
+
+/// Missing means Practice for old pages, bookmarks and API clients.
+pub(super) fn assign(
+ request: &Value,
+ owner: i64,
+ secret: &str,
+) -> Result
+
+ Strict Interview assigns a problem after these checks. Reloading
+ requires preflight again and starts a new attempt with a different
+ problem. Your previous interview progress is not restored.
+
diff --git a/web/interview.js b/web/interview.js
index 271b433c..6b6bc289 100644
--- a/web/interview.js
+++ b/web/interview.js
@@ -1,3 +1,8 @@
+import {
+ previousAssignment,
+ rememberAssignment,
+ resetAssignments,
+} from "./selection.js";
import { createReportRecovery } from "./report-recovery.js";
import { createAudioPlayback } from "./audio-playback.js";
import {
@@ -272,6 +277,7 @@ const replayVersion = globalThis.CODETRIAL_REPLAY_VERSION || 1;
const params = new URLSearchParams(window.location.search);
const editorOptions = editorOptionsFromParams(params);
+const strict = params.get("selection") === "strict";
// Start this independent request while the problem data is loading. It has to
// settle rather than reject: nothing awaits it until init() reaches the sign-in
// gate, and a problem that fails to load throws out of the top-level await
@@ -280,32 +286,21 @@ const editorOptions = editorOptionsFromParams(params);
const sessionPromise = fetch("/api/session")
.then((response) => (response.ok ? response.json() : null))
.catch(() => null);
-// Top-level await: the whole module is written against a known problem, and
-// interview.html loads it as a module, so waiting here beats threading a
-// promise through every consumer below.
-//
-// The failure has to be said out loud. Every consumer below reads `problem`
-// unconditionally, so an unreachable bank would otherwise leave the page on its
-// "Loading interview..." heading forever with a console error nobody sees.
-const problem = await loadProblem(params.get("problem")).catch((error) => {
- const title = document.querySelector("#problem-title");
- if (title)
- title.textContent =
- "This interview could not load its problem. Reload the page.";
- throw error;
-});
-// Started here and awaited nowhere: which tabs are real is a property of the
-// judge, but it is not worth holding a page the candidate can already work in.
-// Every tab is offered until the answer arrives, which is what the row did
-// before this and what it keeps doing if the judge never loads; the run path
-// reports that failure itself.
-const judgePromise = loadJudge(problem.page).catch(() => null);
-// Up here rather than beside the functions that read them: `init()` runs
-// during module evaluation and reaches `initializeCandidateCases` before its
-// first await, so declarations further down are still uninitialized then. The
-// read threw, the catch took it for an empty list, and every reload lost the
-// candidate's saved cases.
-const candidateCaseStorageKey = `codetrial.candidateCases.${problem.page}`;
+// Practice keeps its URL lookup, including legacy aliases and fallback.
+// Strict does not load a problem until preflight and server assignment finish.
+let problem = strict
+ ? null
+ : await loadProblem(params.get("problem")).catch((error) => {
+ const title = document.querySelector("#problem-title");
+ if (title)
+ title.textContent =
+ "This interview could not load its problem. Reload the page.";
+ throw error;
+ });
+let judgePromise = problem ? loadJudge(problem.page).catch(() => null) : null;
+const candidateCaseStorageKey = problem
+ ? `codetrial.candidateCases.${problem.page}`
+ : "";
/// Filled by `applyLanguages`, which `init` calls before anything reads it.
let languages = [];
// A delayed judge may make the requested C tab available after the first
@@ -445,7 +440,7 @@ const state = {
interviewerPresent: false,
agentSpeaking: false,
agentThinking: false,
- codeByLanguage: { ...problem.starterCode },
+ codeByLanguage: { ...problem?.starterCode },
language: "python",
// Thresholds already announced. A latch is released when the room pauses only
// if it was set inside the pause round trip, because the agent drops either
@@ -514,6 +509,23 @@ const state = {
const beforeUnloadGuard = createBeforeUnloadGuard(window);
+// A history restore may revive a whole old page instead of evaluating this
+// module again. Recheck Strict preflight only when the existing unload policy
+// allows leaving: an active attempt or unsaved report must remain available.
+window.addEventListener("pageshow", (event) => {
+ if (
+ !strict ||
+ !event.persisted ||
+ shouldWarnBeforeUnload({
+ endsAt: state.endsAt,
+ reportPersisted: state.reportPersisted,
+ })
+ )
+ return;
+ beforeUnloadGuard.setEnabled(false);
+ window.location.reload();
+});
+
function updateBeforeUnloadGuard() {
beforeUnloadGuard.setEnabled(
shouldWarnBeforeUnload({
@@ -754,6 +766,7 @@ function holdWorkspaceBehindDialogs() {
async function init() {
state.transcript = createTranscriptView(document, nodes.transcriptPanel);
renderRuntimeConfig();
+ document.querySelector("#strict-note").hidden = !strict;
nodes.hideExamples.checked = readStored(HIDE_EXAMPLES_KEY) === "1";
nodes.editorHighlight.hidden = !editorOptions.highlight;
nodes.editorStack.classList.toggle("plain-text", !editorOptions.highlight);
@@ -761,13 +774,17 @@ async function init() {
nodes.editorStack.style.setProperty("--editor-font-family", "inherit");
applyEditorFontSize(readStored(EDITOR_FONT_SIZE_KEY));
applyEditorTheme(readStored(EDITOR_THEME_KEY));
- renderProblem();
- applyLanguages(null);
- setLanguage(
- requestedLanguage && languages.includes(requestedLanguage)
- ? requestedLanguage
- : "python",
- );
+ if (problem) {
+ renderProblem();
+ initializeLanguages();
+ } else {
+ nodes.title.textContent = "Strict Interview";
+ nodes.meta.textContent = "Assigned after media preflight";
+ nodes.editor.disabled = true;
+ nodes.run.disabled = true;
+ nodes.end.disabled = true;
+ nodes.pause.disabled = true;
+ }
bindEvents();
if (whiteboard) initWhiteboard();
else {
@@ -856,20 +873,6 @@ async function init() {
compactJimStageBesideNarrowPanel(
whiteboard ? nodes.boardPanel : nodes.editorPanel,
);
- // After bindEvents, so the callback cannot beat the row it edits: everything
- // above here is synchronous, and a `then` runs no earlier than the next
- // microtask.
- void judgePromise.then((spec) => {
- applyLanguages(spec);
- if (
- state.phase === "live" &&
- !languageChoiceCommitted &&
- requestedLanguage &&
- state.language !== requestedLanguage &&
- languages.includes(requestedLanguage)
- )
- setLanguage(requestedLanguage);
- });
// No enumeration here. Before the preflight grant the browser reports only a
// blank placeholder, so the list is discarded and repainted a few lines down;
// the `toggle` handler covers a panel opened while the preflight is still up.
@@ -902,7 +905,12 @@ async function init() {
// is clear by the time the candidate is actually working rather than holding
// a caption nobody said.
showCaptions();
- await connect(preflight, presenting);
+ const started = await connect(preflight, presenting);
+ if (started === false) return;
+ if (strict) {
+ nodes.end.disabled = false;
+ nodes.pause.disabled = false;
+ }
publishCode();
state.endsAt = Date.now() + durationMin * 60 * 1000;
updateBeforeUnloadGuard();
@@ -1086,7 +1094,7 @@ function announceRestore(message, stillTrue) {
/// not with `window.confirm`, which blocks the page's event loop while it is
/// open and stalls the captions, the timer and the data channel with it.
function restoreStarter() {
- const starter = problem.starterCode[state.language];
+ const starter = problem?.starterCode[state.language];
if (codingClosed() || nodes.editor.disabled || starter === undefined) {
disarmRestore();
return;
@@ -1206,7 +1214,7 @@ function bindEvents() {
});
removeCandidateCase(Number(button.dataset.removeCase));
});
- void initializeCandidateCases();
+ if (problem) void initializeCandidateCases();
for (const select of [nodes.meetOutputSelect, nodes.preflightOutput]) {
select.addEventListener("change", () => {
void applyAudioOutput(select.value);
@@ -1471,8 +1479,7 @@ async function signInRequired() {
if (
!session ||
session.signedIn ||
- !session.loginRequired ||
- session.guestModeAvailable
+ (!strict && (!session.loginRequired || session.guestModeAvailable))
)
return false;
nodes.audioCheck.hidden = true;
@@ -1725,7 +1732,7 @@ function runAudioCheck() {
};
const finish = () => {
- if (!sampleReadiness().ready) return;
+ if (finished || !sampleReadiness().ready) return;
stopChecks();
nodes.audioCheck.hidden = true;
resolve({ userStream: pool.stream, cameraSkipped, cameraSkipReason });
@@ -1931,6 +1938,7 @@ async function connect(preflight, presenting = false) {
// write consent down, and it must not be reachable without one.
if (!consentGiven())
throw new Error("Agree to the recording notice before starting.");
+ const previous = strict ? previousAssignment(tabStorage) : null;
const interviewId = await recordConsent();
state.interviewId = interviewId;
// `hideExamples` goes only when ticked, so Jim is not told the examples are
@@ -1941,7 +1949,15 @@ async function connect(preflight, presenting = false) {
headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS),
body: JSON.stringify({
- problemId: problem.page,
+ ...(strict
+ ? {
+ selectionMode: "strict",
+ ...(params.has("difficulty")
+ ? { difficulties: params.getAll("difficulty") }
+ : {}),
+ ...(previous ? { previousAssignment: previous } : {}),
+ }
+ : { problemId: problem.page }),
durationMin,
interviewId,
interviewLoop,
@@ -1959,18 +1975,43 @@ async function connect(preflight, presenting = false) {
...(!editorOptions.execution ? { codeExecution: false } : {}),
}),
});
- if (!response.ok)
- throw new Error(
- (await response.json()).error || "Failed to create a session.",
+ if (!response.ok) {
+ const failure = await response.json().catch((error) => {
+ throw Object.assign(error, { status: response.status });
+ });
+ throw Object.assign(
+ new Error(failure?.error || "Failed to create a session."),
+ { code: failure?.code, status: response.status },
);
+ }
const connection = await response.json();
state.isGuest = connection.guest === true;
+ if (strict) {
+ // Save before revealing the problem or joining. A later load or join
+ // failure must still exclude this assignment on the next attempt.
+ rememberAssignment(tabStorage, connection.previousAssignment);
+ if (typeof connection.problemPage !== "string" || !connection.problemPage)
+ throw new Error("The server returned no Strict problem.");
+ const assigned = await loadProblem(connection.problemPage);
+ if (assigned.page !== connection.problemPage || assigned.requestedPage)
+ throw new Error(
+ "The assigned problem could not be loaded. Reload to try again.",
+ );
+ problem = assigned;
+ judgePromise = loadJudge(problem.page).catch(() => null);
+ state.codeByLanguage = { ...problem.starterCode };
+ renderProblem();
+ initializeLanguages();
+ void initializeCandidateCases();
+ }
// Before the room, and before anything that reads the length: the replay's
// opening lifecycle event carries the round split, and a split computed
// from the requested length would be the first thing written down.
applyGrantedDuration(connection.durationMin);
await connectLiveKit(connection, preflight, presenting);
state.connected = true;
+ if (strict) updateRunAvailability();
+ if (strict && !whiteboard) nodes.editor.disabled = false;
// After the room, not before it. The server refuses to record an interview
// that has not claimed a room, so starting here rather than alongside the
// consent request is the difference between a recording and a failure
@@ -2008,7 +2049,7 @@ async function connect(preflight, presenting = false) {
}
} catch (error) {
const failure =
- error?.name === "TimeoutError"
+ !strict && error?.name === "TimeoutError"
? new Error(
"The interview connection timed out. Offline practice is ready.",
)
@@ -2028,7 +2069,55 @@ async function connect(preflight, presenting = false) {
state.room = null;
if (joined) void joined.disconnect?.()?.catch?.(() => {});
stopPreflight(preflight);
- const degraded = providerUiState("degraded", failure?.message);
+ const reason =
+ strict &&
+ (error?.name === "TimeoutError" || [408, 504].includes(error?.status))
+ ? "Strict Interview connection timed out. Return to the lobby or retry media preflight."
+ : failure?.message;
+ if (strict) {
+ const message = reason || "Could not start Strict Interview.";
+ setAgentStateLabel("Not connected");
+ setBanner("connection", message);
+ // Selection refusals need a new choice, not another consent and token
+ // request with the same inputs. Keep the receipt unless the candidate
+ // explicitly starts a new sequence after the server rejects it.
+ nodes.editor.disabled = true;
+ nodes.run.disabled = true;
+ nodes.audioStatus.textContent = message;
+ nodes.audioCheck.hidden = false;
+ for (const control of nodes.audioCheck.querySelectorAll(
+ "button, input, select",
+ ))
+ control.disabled = control !== nodes.audioLeave;
+ const restart = error?.code === "strict_assignment_invalid";
+ nodes.audioJoin.hidden = [
+ "strict_no_alternative",
+ "strict_difficulty_invalid",
+ ].includes(error?.code);
+ nodes.audioJoin.disabled = nodes.audioJoin.hidden;
+ nodes.audioJoin.textContent = restart
+ ? "Start a new selection sequence"
+ : "Retry media preflight";
+ if (!nodes.audioJoin.hidden)
+ nodes.audioJoin.addEventListener("click", () => {
+ if (nodes.audioJoin.disabled) return;
+ nodes.audioJoin.disabled = true;
+ if (restart) {
+ try {
+ resetAssignments(tabStorage);
+ if (previousAssignment(tabStorage) !== null) throw new Error();
+ } catch {
+ nodes.audioStatus.textContent =
+ "Could not clear the previous assignment. Allow site storage or return to the lobby.";
+ nodes.audioJoin.disabled = false;
+ return;
+ }
+ }
+ window.location.reload();
+ });
+ return false;
+ }
+ const degraded = providerUiState("degraded", reason);
setAgentStateLabel(degraded.label);
// A degraded start without a reason reads as the product working. The server
// says why it refused, in words written for a candidate, and a busy server
@@ -2125,10 +2214,12 @@ async function connectLiveKit(connection, preflight, presenting = false) {
// on its own, so the candidate is told to wait rather than to restart.
room.on(livekit.RoomEvent.Reconnecting, () => {
state.connected = false;
+ if (strict) updateRunAvailability();
setBanner("connection", providerUiState("reconnecting").message);
});
room.on(livekit.RoomEvent.Reconnected, () => {
state.connected = true;
+ if (strict) updateRunAvailability();
setBanner("connection", "");
// Everything held during the gap, in order, then the current buffer on top
// so Jim is reading what the candidate is actually looking at rather than
@@ -2155,6 +2246,7 @@ async function connectLiveKit(connection, preflight, presenting = false) {
room.on(livekit.RoomEvent.Disconnected, () => {
audioPlayback.release(room);
state.connected = false;
+ if (strict) updateRunAvailability();
// Terminal, not transient: LiveKit fires Reconnecting for a recoverable
// blip and only reaches here when it has given up. Dropping the reference
// is what makes the banner's own advice possible: `endInterview` hides the
@@ -2164,9 +2256,13 @@ async function connectLiveKit(connection, preflight, presenting = false) {
// waiting on an overlay for a report that could not arrive.
if (state.phase === "report_recovery") reportRecovery.finish();
state.room = null;
+ if (strict) setAgentStateLabel("Not connected");
setBanner(
"connection",
- providerUiState("degraded", "The interview connection dropped.").message,
+ strict
+ ? "The Strict Interview connection dropped."
+ : providerUiState("degraded", "The interview connection dropped.")
+ .message,
);
console.warn("codetrial room_disconnected");
});
@@ -2341,6 +2437,7 @@ const reportRecovery = createReportRecovery({
},
offer: (ready, cooldown, cause) => {
state.phase = "report_recovery";
+ if (strict) updateRunAvailability();
if (!ready) stopEndingEscape();
globalThis.clearTimeout(frameworkHintTimer);
nodes.frameworkHint.hidden = true;
@@ -2700,6 +2797,7 @@ async function toggleMicrophone() {
}
function renderProblem() {
+ if (!problem) return;
// Difficulty only. The topic tags name the technique, and the heading is the
// scenario's own title rather than the published problem it was written from.
nodes.title.textContent = problem.title;
@@ -2723,6 +2821,29 @@ function selectTab(tab) {
nodes.transcriptTab.classList.toggle("selected", transcript);
}
+function initializeLanguages() {
+ applyLanguages(null);
+ setLanguage(
+ requestedLanguage && languages.includes(requestedLanguage)
+ ? requestedLanguage
+ : "python",
+ );
+ // Setup and event binding finish synchronously before this callback runs.
+ // A late judge may enable the requested language, but cannot replace a
+ // language or buffer the candidate has already committed to.
+ void judgePromise.then((spec) => {
+ applyLanguages(spec);
+ if (
+ state.phase === "live" &&
+ !languageChoiceCommitted &&
+ requestedLanguage &&
+ state.language !== requestedLanguage &&
+ languages.includes(requestedLanguage)
+ )
+ setLanguage(requestedLanguage);
+ });
+}
+
/// Unavailable labels and the active selector's title come from `harnessGap`,
/// so a language is never withheld for a reason the candidate cannot find. An
/// already-active buffer stays visible: hiding it would replace the candidate's
@@ -3233,6 +3354,10 @@ function setResultsOpen(open) {
}
async function runTests() {
+ if (strict) {
+ updateRunAvailability();
+ if (nodes.run.disabled) return;
+ }
if (!editorOptions.execution) return;
languageChoiceCommitted = true;
flushPendingCodePublish();
@@ -3325,12 +3450,12 @@ async function initializeCandidateCases() {
const wasActive = Boolean(readStored(activeSessionKey, tabStorage));
writeStored(activeSessionKey, "1", tabStorage);
- if (!isReload && wasActive) {
+ if (!strict && !isReload && wasActive) {
writeStored(candidateCaseStorageKey, "", tabStorage);
}
try {
const saved =
- !isReload && wasActive
+ strict || (!isReload && wasActive)
? []
: JSON.parse(readStored(candidateCaseStorageKey, tabStorage) || "[]");
state.candidateCases = Array.isArray(saved)
@@ -3398,7 +3523,7 @@ async function addCandidateCaseNow() {
writeStored(
candidateCaseStorageKey,
JSON.stringify(state.candidateCases),
- tabStorage,
+ strict ? null : tabStorage,
);
nodes.candidateCaseInput.value = "";
nodes.candidateCaseExpected.value = "";
@@ -3457,7 +3582,7 @@ function removeCandidateCase(index) {
writeStored(
candidateCaseStorageKey,
JSON.stringify(state.candidateCases),
- tabStorage,
+ strict ? null : tabStorage,
);
renderCandidateCases();
// renderCandidateCases leaves the status alone on an empty list, so the
@@ -3489,6 +3614,7 @@ function updateRunAvailability() {
if (restoreOff) disarmRestore();
nodes.run.disabled =
!editorOptions.execution ||
+ (strict && !state.connected) ||
state.runningTests ||
state.paused ||
codingClosed() ||
@@ -3609,6 +3735,7 @@ function endInterview(reason) {
if (state.phase !== "live" || state.reportReceiving) return;
const exampleFinished = exampleBoard?.finish();
state.phase = "ending";
+ if (strict) updateRunAvailability();
// The hint outranks the ending overlay in the stacking order, so a candidate
// who ends while it is still up would read the report status through it.
globalThis.clearTimeout(frameworkHintTimer);
@@ -3848,6 +3975,7 @@ function reportRenderFailed(error, { offlineSummary }) {
}
function renderReport() {
+ if (strict) updateRunAvailability();
// The interview is over. Both report paths, agent-sent and offline, land
// here, so this is the one place that has to stop the render loop and the
// one place that has to release the devices. Only the agent-sent path used
diff --git a/web/problem-grid.js b/web/problem-grid.js
index 0f1ad01f..7be29f83 100644
--- a/web/problem-grid.js
+++ b/web/problem-grid.js
@@ -22,7 +22,7 @@ export function mountProblemGrid() {
// Measure the live layout, including changes to any setup panel's height.
function drawOutline() {
- const connected = columns.matches && picker.open;
+ const connected = columns.matches && picker.open && !picker.hidden;
lobby.classList.toggle("problem-picker-connected", connected);
outline.toggleAttribute("hidden", !connected);
if (!connected) return;
@@ -83,14 +83,16 @@ export function mountProblemGrid() {
const hadFocus = grid.contains(focused);
if (columns.matches) {
if (grid.previousElementSibling !== panel) panel.after(grid);
- if (picker.open) grid.removeAttribute("hidden");
+ // Detached desktop cards must inherit the picker's hidden state.
+ if (picker.hidden) grid.hidden = true;
+ else if (picker.open) grid.removeAttribute("hidden");
else grid.setAttribute("hidden", "until-found");
} else {
if (grid.parentElement !== picker) home.after(grid);
- grid.hidden = false;
+ grid.hidden = picker.hidden;
}
drawOutline();
- if (hadFocus) {
+ if (hadFocus && !picker.hidden) {
if (!picker.open) summary.focus({ preventScroll: true });
else focused.focus({ preventScroll: true });
}
@@ -101,7 +103,7 @@ export function mountProblemGrid() {
const stateObserver = new MutationObserver(update);
stateObserver.observe(picker, {
attributes: true,
- attributeFilter: ["open"],
+ attributeFilter: ["open", "hidden"],
});
columns.addEventListener("change", update);
update();
diff --git a/web/selection.js b/web/selection.js
new file mode 100644
index 00000000..32c57e0d
--- /dev/null
+++ b/web/selection.js
@@ -0,0 +1,38 @@
+// One previous assignment per tab. This is selection metadata, never recovery
+// state, and the server checks its signature and owner before using it.
+export const STRICT_ASSIGNMENT_KEY = "codetrial.strictAssignment";
+
+const storageError =
+ "Strict Interview needs session storage to remember its previous assignment. Allow site storage and reload, or return to the lobby.";
+
+export function previousAssignment(storage) {
+ try {
+ if (!storage) throw new Error();
+ const previous = storage.getItem(STRICT_ASSIGNMENT_KEY);
+ // Test writes before asking for consent or allocating a room. Restore the
+ // original absence rather than leaving a probe looking like a receipt.
+ storage.setItem(STRICT_ASSIGNMENT_KEY, previous ?? "");
+ if (storage.getItem(STRICT_ASSIGNMENT_KEY) !== (previous ?? ""))
+ throw new Error();
+ if (previous === null) storage.removeItem(STRICT_ASSIGNMENT_KEY);
+ return previous;
+ } catch {
+ throw new Error(storageError);
+ }
+}
+
+export function rememberAssignment(storage, receipt) {
+ if (typeof receipt !== "string" || !receipt || receipt.length > 512)
+ throw new Error("The server returned an invalid Strict assignment.");
+ try {
+ storage.setItem(STRICT_ASSIGNMENT_KEY, receipt);
+ if (storage.getItem(STRICT_ASSIGNMENT_KEY) !== receipt) throw new Error();
+ } catch {
+ throw new Error(storageError);
+ }
+}
+
+export function resetAssignments(storage) {
+ previousAssignment(storage);
+ storage.removeItem(STRICT_ASSIGNMENT_KEY);
+}
diff --git a/web/styles.css b/web/styles.css
index 27d955e8..74652adc 100644
--- a/web/styles.css
+++ b/web/styles.css
@@ -3329,6 +3329,7 @@ body:not([data-recording]) .replay-detail > :not(header) {
}
.lobby .account-input,
+.lobby #selection-mode,
.lobby .practice-filters select,
.lobby .progress-filters select,
.lobby .interview-context input:not([type="checkbox"]),