diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 16d73b055e..0e288cc233 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -92,11 +92,52 @@ impl Default for CompactionConfig { /// Minimum non-whitespace characters for a usable successor summary. /// Below this (or missing required section headings), treat as degenerate and /// retry once rather than shipping amnesia (compactionidea failure ladder). +const COMPACTION_MIN_NON_WHITESPACE_CHARS: usize = 48; const COMPACTION_LANGUAGE_CONTRACT: &str = "Use the natural language of the most recent \ substantive user message for reasoning and user-facing prose. Keep code, identifiers, paths, \ commands, logs, tool payloads, quotations, and the English structural labels verbatim. English \ scaffolding is not a request to switch languages."; +/// Placeholder / non-text summaries that must never replace real history. +const COMPACTION_PLACEHOLDER_SUMMARIES: &[&str] = &[ + "(no summary available)", + "no summary available", + "n/a", + "none", + "null", + "undefined", + "...", + "…", + "tbd", + "todo", + "placeholder", +]; + +/// Whether a compaction summary is substantive enough to replace history. +#[must_use] +pub fn summary_is_usable(summary: &str) -> bool { + let trimmed = summary.trim(); + if trimmed.is_empty() { + return false; + } + let lower = trimmed.to_ascii_lowercase(); + if COMPACTION_PLACEHOLDER_SUMMARIES + .iter() + .any(|placeholder| lower == *placeholder) + { + return false; + } + let non_ws: String = trimmed.chars().filter(|ch| !ch.is_whitespace()).collect(); + if non_ws.chars().count() < COMPACTION_MIN_NON_WHITESPACE_CHARS { + return false; + } + // Punctuation-only / symbol-only bodies are not handoff text. + if non_ws.chars().all(|ch| !ch.is_alphanumeric()) { + return false; + } + true +} + /// Failure kind for compaction LLM calls (deterministic vs transient). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CompactionFailureKind { @@ -405,8 +446,13 @@ fn estimate_retained_floor_conservative( let retained_tokens = estimate_tokens(&retained).saturating_mul(3).div_ceil(2); let framing = retained.len().saturating_mul(12).saturating_add(48); let anchors = user_anchors_section(config.workspace.as_deref()); - let summary_scaffolding_tokens = - estimate_text_tokens_conservative(&build_compaction_summary_block_text("", &anchors)); + let summary_scaffolding_tokens = estimate_text_tokens_conservative( + &build_compaction_summary_block_text( + // Scaffolding-size probe only; never committed as a real summary. + "Progress checkpoint placeholder used only to size compaction scaffolding tokens for pressure estimates.", + &anchors, + ), + ); // Post-compaction the committed summary is REPLACED, not stacked, so prior // summary blocks must not inflate the floor. Count only the exact installed @@ -955,10 +1001,21 @@ pub async fn compact_messages_safe( return Ok(CompactionResult { messages: sanitize_retained_messages(msgs), summary_prompt: prompt, + // Includes quality-gate retries: each failed attempt + // advances `attempt` before a successful compact. retries_used: attempt, }); } Err(e) => { + let message = e.to_string(); + if message.contains("quality gate") { + logging::warn(format!( + "Compaction quality gate rejected summary on attempt {}: {message}", + attempt + 1 + )); + last_error = Some(e); + continue; + } // Only retry on transient errors if !is_transient_error(&e) { return Err(e); @@ -973,12 +1030,13 @@ pub async fn compact_messages_safe( } fn build_compaction_summary_block_text(summary: &str, anchors: &str) -> String { + // Callers must only pass usable summaries. Never substitute a placeholder + // that would erase real history under the guise of a successful compact. let summary = summary.trim(); - let summary = if summary.is_empty() { - "(no summary available)" - } else { - summary - }; + debug_assert!( + summary_is_usable(summary), + "compaction must not commit an unusable summary" + ); let mut text = format!("{SUMMARY_HEADER}\n\n{summary}"); text.push_str(anchors); text @@ -1053,6 +1111,11 @@ pub async fn compact_messages( } let summary = create_summary(client, messages, config).await?; + if !summary_is_usable(&summary) { + anyhow::bail!( + "Compaction summary failed the quality gate (empty, placeholder, punctuation-only, or non-text); history was not replaced." + ); + } let anchors = user_anchors_section(config.workspace.as_deref()); let checkpoint_text = build_compaction_summary_block_text(&summary, &anchors); let summary_block = SystemBlock { @@ -2111,4 +2174,18 @@ mod tests { assert_eq!(result.retries_used, 2); assert!(result.messages.is_empty()); } + + #[test] + fn unusable_compaction_summaries_fail_the_quality_gate() { + assert!(!summary_is_usable("")); + assert!(!summary_is_usable(" \n\t ")); + assert!(!summary_is_usable("...")); + assert!(!summary_is_usable("!!! ??? ---")); + assert!(!summary_is_usable("(no summary available)")); + assert!(!summary_is_usable("n/a")); + assert!(!summary_is_usable("short")); + assert!(summary_is_usable( + "Current progress: migrated the auth middleware. Next: cover the refresh-token path and fix the flake in session resume." + )); + } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index ded8b8a672..b862df95f6 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -4158,6 +4158,15 @@ impl Engine { self.session.total_usage.add(&turn.usage); self.record_goal_usage_for_turn(&turn.usage, turn.elapsed()); + // On turn cancellation, cancel and join turn-owned (non-detached) + // children before the mailbox seal / TurnComplete. Explicit + // detached=true children keep their own tokens. Successful turns leave + // turn-owned children running so completion sentinels can wake the + // parent on a later turn. + if status == TurnOutcomeStatus::Interrupted { + let mut manager = self.subagent_manager.write().await; + manager.cancel_and_join_turn_owned_agents().await; + } // Seal and fully forward every accepted mailbox envelope before the // terminal event. This is the durability barrier for child usage: an // event can no longer arrive after `TurnComplete` and be mistaken for diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 098fc26acd..1881382d5c 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -865,10 +865,14 @@ pub async fn run_http_server( println!("Codewhale web enabled at http://{bound_addr}/"); let bootstrap_url = web::bootstrap_url(bound_addr, &bootstrap); if let Err(error) = crate::utils::open_url(&bootstrap_url) { - scheduler_cancel.cancel(); - scheduler_handle.abort(); - return Err(error) - .context("Failed to open the Codewhale web client in the default browser"); + // A missing or broken browser opener must not tear down a healthy + // Runtime. Print the single-use bootstrap URL for manual recovery. + eprintln!( + "warning: could not open the default browser ({error:#}). \ + Runtime is still serving. Open this single-use bootstrap URL \ + within ten minutes to authenticate the local web client:\n {bootstrap_url}" + ); + println!("Manual bootstrap (single-use, expires in 10 minutes):\n {bootstrap_url}"); } } let is_loopback = options.host == "127.0.0.1" || options.host == "::1"; diff --git a/crates/tui/src/runtime_api/web.rs b/crates/tui/src/runtime_api/web.rs index c8e2857938..b5d4dbe389 100644 --- a/crates/tui/src/runtime_api/web.rs +++ b/crates/tui/src/runtime_api/web.rs @@ -14,7 +14,7 @@ use super::RuntimeApiState; const WEB_HTML: &str = include_str!("../runtime_web/index.html"); const WEB_CSS: &str = include_str!("../runtime_web/styles.css"); const WEB_JS: &str = include_str!("../runtime_web/app.mjs"); -const BOOTSTRAP_TTL: Duration = Duration::from_secs(120); +const BOOTSTRAP_TTL: Duration = Duration::from_secs(10 * 60); const WEB_SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60); const BOOTSTRAP_PREFIX: &str = "cwwb_"; const WEB_SESSION_PREFIX: &str = "cwws_"; @@ -226,6 +226,11 @@ fn secure_headers(response: &mut Response, content_type: &'static str) { mod tests { use super::*; + #[test] + fn bootstrap_ttl_is_ten_minutes_for_manual_recovery() { + assert_eq!(BOOTSTRAP_TTL, Duration::from_secs(10 * 60)); + } + #[test] fn bootstrap_is_loopback_only_one_time_and_expires() { let (state, nonce) = diff --git a/crates/tui/src/runtime_web/app.mjs b/crates/tui/src/runtime_web/app.mjs index 3bf84508ae..0b7c3884d9 100644 --- a/crates/tui/src/runtime_web/app.mjs +++ b/crates/tui/src/runtime_web/app.mjs @@ -285,6 +285,113 @@ export function resolveApprovalTarget(approvalId, target, streamState) { return { ok: true, threadId: reply.threadId, approvalId }; } +// Resolve a pending user-input submission to the live thread that owns it. +export function resolveUserInputTarget(inputId, target, streamState) { + const reply = resolveReplyTarget(target, streamState); + if (!reply.ok) return reply; + if (!inputId) return { ok: false, reason: "no-user-input" }; + if (!streamState || streamState.threadId !== reply.threadId) { + return { ok: false, reason: "stale-target" }; + } + if (!streamState.userInputs || !streamState.userInputs.has(inputId)) { + return { ok: false, reason: "stale-user-input" }; + } + return { ok: true, threadId: reply.threadId, inputId }; +} + +/** + * Collect answers with TUI parity: Other is always available, and each + * question yields the exact answer cardinality the control implies + * (one for single-select; one-or-more for multi-select). + */ +export function collectUserInputAnswers(groups) { + const answers = []; + for (const group of groups) { + const selected = []; + for (const control of group.controls) { + if (control.input?.checked) { + selected.push({ + id: group.question.id, + label: control.label, + value: control.value, + }); + } + } + const otherValue = group.other?.value?.trim?.() || ""; + if (otherValue) { + selected.push({ id: group.question.id, label: "Other", value: otherValue }); + } + if (selected.length === 0) { + return { + ok: false, + reason: `Choose an answer for ${group.question.header || group.question.question || "each question"}.`, + }; + } + if (!group.question.multi_select && selected.length !== 1) { + return { + ok: false, + reason: `Pick exactly one answer for ${group.question.header || group.question.question || "each question"}.`, + }; + } + answers.push(...selected); + } + return { ok: true, answers }; +} + +/** Compact preview for long MCP / tool failure receipts. */ +export function compactFailureSummary(summary, detail, maxChars = 160) { + const head = String(summary || "").trim(); + const body = String(detail || "").trim(); + if (!body || body === head) return head || "Failed"; + if (body.length <= maxChars && !looksLikeMcpOrToolFailure(head, body)) { + return head || body.slice(0, maxChars); + } + const preview = (head || body).replace(/\s+/g, " ").slice(0, maxChars); + return preview.endsWith("…") ? preview : `${preview}…`; +} + +export function looksLikeMcpOrToolFailure(summary, detail) { + const text = `${summary || ""}\n${detail || ""}`.toLowerCase(); + return ( + text.includes("mcp") + || text.includes("tool failed") + || text.includes("tool error") + || text.includes("server error") + || text.includes("traceback") + || (detail && detail.length > 280 && /failed|error|exception/i.test(text)) + ); +} + +/** Capture open disclosures + selection so a stream re-render can restore them. */ +export function captureTranscriptChrome(root) { + const openIds = []; + const selectedText = globalThis.getSelection?.()?.toString?.() || ""; + if (!root) return { openIds, selectedText, focusItemId: null, focusIsOther: false }; + for (const details of root.querySelectorAll("details[data-item-id][open]")) { + openIds.push(details.getAttribute("data-item-id")); + } + const active = root.ownerDocument?.activeElement; + let focusItemId = null; + let focusIsOther = false; + if (active && root.contains(active)) { + focusItemId = active.closest?.("[data-item-id]")?.getAttribute("data-item-id") || null; + focusIsOther = active.classList?.contains("other-answer") || active.tagName === "TEXTAREA"; + } + return { openIds, selectedText, focusItemId, focusIsOther }; +} + +export function restoreTranscriptChrome(root, chrome) { + if (!root || !chrome) return; + const escape = (value) => + (globalThis.CSS && typeof globalThis.CSS.escape === "function") + ? globalThis.CSS.escape(value) + : String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + for (const id of chrome.openIds || []) { + const details = root.querySelector(`details[data-item-id="${escape(id)}"]`); + if (details) details.open = true; + } +} + // Human-readable reason for a refusal, for the status banner. export function refusalMessage(reason) { switch (reason) { @@ -294,10 +401,16 @@ export function refusalMessage(reason) { return "That thread is no longer the selected one — nothing was sent."; case "stale-approval": return "That request was already answered or has expired — nothing was sent."; + case "stale-user-input": + return "That question was already answered or has expired — nothing was sent."; case "no-approval": return "No approval was identified — nothing was sent."; - default: + case "no-user-input": + return "No pending question was identified — nothing was sent."; + case "no-target": return "Select a live thread first — nothing was sent."; + default: + return "Nothing was sent."; } } @@ -378,6 +491,8 @@ function appendItemDelta(state, itemId, payload) { function startBrowserClient() { const dom = { shell: document.querySelector("#app-shell"), + rail: document.querySelector("#thread-rail"), + session: document.querySelector("main.session"), railOpen: document.querySelector("#rail-open"), railClose: document.querySelector("#rail-close"), railScrim: document.querySelector("#rail-scrim"), @@ -427,6 +542,8 @@ function startBrowserClient() { reconnectTimer: null, generation: 0, searchTimer: null, + railPreviousFocus: null, + railKeyHandler: null, }; function element(tag, className, text) { @@ -436,9 +553,81 @@ function startBrowserClient() { return created; } + function railFocusable() { + if (!dom.rail) return []; + return Array.from( + dom.rail.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter((node) => node.getClientRects().length > 0); + } + + function releaseRailModal() { + if (dom.session) dom.session.inert = false; + if (dom.rail) { + dom.rail.removeAttribute("aria-modal"); + dom.rail.removeAttribute("role"); + } + if (app.railKeyHandler) { + document.removeEventListener("keydown", app.railKeyHandler, true); + app.railKeyHandler = null; + } + } + function closeRail() { + const restore = app.railPreviousFocus; + app.railPreviousFocus = null; + releaseRailModal(); dom.shell.classList.remove("rail-visible"); - dom.railOpen.focus({ preventScroll: true }); + const target = restore && typeof restore.focus === "function" ? restore : dom.railOpen; + target?.focus?.({ preventScroll: true }); + } + + function openRail() { + if (dom.shell.classList.contains("rail-visible")) return; + app.railPreviousFocus = document.activeElement; + dom.shell.classList.add("rail-visible"); + if (dom.session) dom.session.inert = true; + if (dom.rail) { + dom.rail.setAttribute("role", "dialog"); + dom.rail.setAttribute("aria-modal", "true"); + } + app.railKeyHandler = (event) => { + if (!dom.shell.classList.contains("rail-visible")) return; + if (event.key === "Escape") { + event.preventDefault(); + closeRail(); + return; + } + if (event.key !== "Tab") return; + const candidates = railFocusable(); + const first = candidates[0]; + const last = candidates[candidates.length - 1]; + if (!first || !last) { + event.preventDefault(); + return; + } + const active = document.activeElement; + if (event.shiftKey && (active === first || !dom.rail?.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || !dom.rail?.contains(active))) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", app.railKeyHandler, true); + requestAnimationFrame(() => { + (railFocusable()[0] || dom.railClose)?.focus?.({ preventScroll: true }); + }); + } + + function syncVisualViewport() { + const viewport = globalThis.visualViewport; + const height = viewport?.height || globalThis.innerHeight || 0; + if (height > 0) { + document.documentElement.style.setProperty("--vv-height", `${Math.round(height)}px`); + } } function setConnection(kind, message) { @@ -774,11 +963,16 @@ function startBrowserClient() { isCurrent: () => generation === app.generation && threadId === app.selectedThreadId, }); if (!subscribed) return; + // Gap state clears only after both the replacement snapshot and the + // resubscription have succeeded. + app.streamGap = false; renderAll(); showStatus(""); setConnection("ready", "Local runtime connected"); } catch (error) { if (generation !== app.generation || threadId !== app.selectedThreadId) return; + // Leave streamGap true so the connection label stays honest until a + // later recovery actually lands a snapshot + subscription. showStatus(`Could not refresh the thread snapshot: ${error.message}`); setConnection("error", "Runtime recovery failed"); app.reconnectTimer = setTimeout( @@ -838,19 +1032,48 @@ function startBrowserClient() { function renderTranscript(preserveScroll) { const wasNearBottom = dom.transcript.scrollHeight - dom.transcript.scrollTop - dom.transcript.clientHeight < 120; - dom.transcript.replaceChildren(); - if (!app.threadState.thread) { - dom.transcript.append(emptyState("Your local agent, in the browser.", "Create a thread or choose one from the rail. This client uses the same Runtime as the terminal.")); - return; + const chrome = captureTranscriptChrome(dom.transcript); + const liveBodies = new Map(); + for (const node of dom.transcript.querySelectorAll("[data-item-id][data-live-body='true']")) { + liveBodies.set(node.getAttribute("data-item-id"), node); } - if (app.threadState.itemOrder.length === 0) { - dom.transcript.append(emptyState("Ready for a task.", "Send a message below. Model, mode, and permission posture come from the Runtime and are shown read-only above.")); - return; + + // Prefer in-place body patches for growing agent text so assistive tech + // and selection are not reset on every token. + let patchedOnly = false; + if ( + preserveScroll + && liveBodies.size > 0 + && app.threadState.itemOrder.length > 0 + && dom.transcript.childElementCount === app.threadState.itemOrder.length + ) { + patchedOnly = true; + for (const itemId of app.threadState.itemOrder) { + const item = app.threadState.items.get(itemId); + const existing = liveBodies.get(itemId); + if (!item || !existing || item.kind !== "agent_message" || item.status !== "in_progress") { + patchedOnly = false; + break; + } + const detail = item.detail || item.summary || ""; + if (existing.textContent !== detail) setSafeText(existing, detail); + } } - for (const itemId of app.threadState.itemOrder) { - const item = app.threadState.items.get(itemId); - if (!item) continue; - dom.transcript.append(renderItem(item)); + + if (!patchedOnly) { + dom.transcript.replaceChildren(); + if (!app.threadState.thread) { + dom.transcript.append(emptyState("Your local agent, in the browser.", "Create a thread or choose one from the rail. This client uses the same Runtime as the terminal.")); + } else if (app.threadState.itemOrder.length === 0) { + dom.transcript.append(emptyState("Ready for a task.", "Send a message below. Model, mode, and permission posture come from the Runtime and are shown read-only above.")); + } else { + for (const itemId of app.threadState.itemOrder) { + const item = app.threadState.items.get(itemId); + if (!item) continue; + dom.transcript.append(renderItem(item)); + } + restoreTranscriptChrome(dom.transcript, chrome); + } } if (!preserveScroll || wasNearBottom) { requestAnimationFrame(() => { @@ -872,24 +1095,49 @@ function startBrowserClient() { if (item.kind === "user_message" || item.kind === "agent_message") { const role = item.kind === "user_message" ? "user" : "agent"; const card = element("article", `message ${role} ${item.status === "in_progress" ? "in-progress" : ""}`.trim()); + card.dataset.itemId = item.id; card.append(element("div", "message-label", role === "user" ? "You" : "Codewhale")); - card.append(element("div", "message-body", detail)); + const body = element("div", "message-body", detail); + body.dataset.itemId = item.id; + if (role === "agent" && item.status === "in_progress") { + // Growing token streams must not re-announce the whole reply. + body.dataset.liveBody = "true"; + body.setAttribute("aria-live", "off"); + } + card.append(body); return card; } if (item.kind === "agent_reasoning") { const reasoning = element("article", "reasoning"); + reasoning.dataset.itemId = item.id; const disclosure = element("details"); + disclosure.dataset.itemId = item.id; disclosure.append(element("summary", "", item.status === "in_progress" ? "Reasoning…" : "Reasoning")); disclosure.append(element("pre", "", detail)); reasoning.append(disclosure); return reasoning; } - const receipt = element("article", `receipt ${item.status === "failed" ? "failed" : ""}`.trim()); + const failed = item.status === "failed"; + const receipt = element("article", `receipt ${failed ? "failed" : ""}`.trim()); + receipt.dataset.itemId = item.id; receipt.append(element("div", "receipt-label", `${humanize(item.kind)} · ${humanize(item.status)}`)); + const longFailure = failed && looksLikeMcpOrToolFailure(item.summary, detail); + if (longFailure) { + receipt.append( + element("div", "receipt-summary", compactFailureSummary(item.summary, detail)), + ); + const disclosure = element("details"); + disclosure.dataset.itemId = item.id; + disclosure.append(element("summary", "", "Show full error")); + disclosure.append(element("pre", "", detail || item.summary || "")); + receipt.append(disclosure); + return receipt; + } receipt.append(element("div", "receipt-summary", item.summary || detail || humanize(item.kind))); if (detail && detail !== item.summary) { const disclosure = element("details"); + disclosure.dataset.itemId = item.id; disclosure.append(element("summary", "", "Show receipt")); disclosure.append(element("pre", "", detail)); receipt.append(disclosure); @@ -954,6 +1202,7 @@ function startBrowserClient() { function renderUserInput(inputId, envelope) { const card = element("form", "attention-card"); + card.dataset.inputId = inputId; card.append(element("p", "eyebrow", "Input required")); card.append(element("h2", "", "Codewhale has a question")); const questions = Array.isArray(envelope.request?.questions) ? envelope.request.questions : []; @@ -975,15 +1224,13 @@ function startBrowserClient() { fieldset.append(label); controls.push({ input, label: option.label || "", value: option.label || "" }); } - let other = null; - if (question.allow_free_text) { - other = document.createElement("input"); - other.className = "other-answer"; - other.type = "text"; - other.placeholder = "Other response"; - other.setAttribute("aria-label", `${question.header || "Question"} other response`); - fieldset.append(other); - } + // TUI parity: Other is always reachable, even when allow_free_text is false. + const other = document.createElement("input"); + other.className = "other-answer"; + other.type = "text"; + other.placeholder = "Other response"; + other.setAttribute("aria-label", `${question.header || "Question"} other response`); + fieldset.append(other); card.append(fieldset); groups.push({ question, controls, other }); } @@ -994,25 +1241,25 @@ function startBrowserClient() { card.append(actions); card.addEventListener("submit", async (event) => { event.preventDefault(); - const answers = []; - for (const group of groups) { - for (const control of group.controls) { - if (control.input.checked) { - answers.push({ id: group.question.id, label: control.label, value: control.value }); - } - } - const otherValue = group.other?.value.trim(); - if (otherValue) answers.push({ id: group.question.id, label: "Other", value: otherValue }); - if (!answers.some((answer) => answer.id === group.question.id)) { - showStatus(`Choose an answer for ${group.question.header || group.question.question || "each question"}.`); - return; - } + const resolved = resolveUserInputTarget(inputId, app.target, app.threadState); + if (!resolved.ok) { + showStatus(refusalMessage(resolved.reason)); + renderAttention(); + return; + } + const collected = collectUserInputAnswers(groups); + if (!collected.ok) { + showStatus(collected.reason); + return; } try { - await api(`/v1/user-input/${encodeURIComponent(app.selectedThreadId)}/${encodeURIComponent(inputId)}`, { - method: "POST", - body: JSON.stringify({ answers }), - }); + await api( + `/v1/user-input/${encodeURIComponent(resolved.threadId)}/${encodeURIComponent(resolved.inputId)}`, + { + method: "POST", + body: JSON.stringify({ answers: collected.answers }), + }, + ); app.threadState.userInputs.delete(inputId); showStatus(""); renderAttention(); @@ -1177,9 +1424,13 @@ function startBrowserClient() { if (globalThis.matchMedia("(max-width: 800px)").matches) closeRail(); } - dom.railOpen.addEventListener("click", () => dom.shell.classList.add("rail-visible")); + dom.railOpen.addEventListener("click", openRail); dom.railClose.addEventListener("click", closeRail); dom.railScrim.addEventListener("click", closeRail); + syncVisualViewport(); + globalThis.visualViewport?.addEventListener("resize", syncVisualViewport); + globalThis.visualViewport?.addEventListener("scroll", syncVisualViewport); + globalThis.addEventListener("resize", syncVisualViewport); dom.newThread.addEventListener("click", createThread); dom.rename.addEventListener("click", openRenameDialog); dom.archive.addEventListener("click", archiveThread); diff --git a/crates/tui/src/runtime_web/index.html b/crates/tui/src/runtime_web/index.html index a0caa6dd8c..9f5f036f32 100644 --- a/crates/tui/src/runtime_web/index.html +++ b/crates/tui/src/runtime_web/index.html @@ -80,7 +80,7 @@

Choose a thread

-
+

Your local agent, in the browser.

diff --git a/crates/tui/src/runtime_web/styles.css b/crates/tui/src/runtime_web/styles.css index 047d21b3dd..0f37512145 100644 --- a/crates/tui/src/runtime_web/styles.css +++ b/crates/tui/src/runtime_web/styles.css @@ -34,6 +34,7 @@ body { width: 100%; min-width: 280px; height: 100%; + height: var(--vv-height, 100dvh); margin: 0; overflow: hidden; background: var(--ink-0); @@ -1009,6 +1010,24 @@ noscript { } } +@media (pointer: coarse) { + .icon-button, + .quiet-button, + .primary-button, + .send-button, + .new-thread, + .thread-row, + .answer-option, + .rail-open, + .rail-close { + min-height: 44px; + } + + .answer-option { + padding: 12px 14px; + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 34c59a6537..0b79b07569 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1574,6 +1574,9 @@ pub(crate) struct SubAgentSpawnOptions { /// Checkpoint resume: preserve the interrupted child's runtime posture /// instead of rebuilding it from the caller's role. pub preserve_runtime_profile: Option, + /// When true, the child is independently durable across parent-turn + /// cancellation. Default false binds the child to the initiating turn. + pub detached: bool, } #[derive(Debug, Clone)] @@ -1808,6 +1811,10 @@ struct SpawnRequest { /// → verify). The source must be settled (not running), in the same /// workspace, and reachable by the spawning agent. resume_from: Option, + /// When true, the child uses an independent cancellation token and survives + /// parent-turn cancellation. Default false: the child is owned by the + /// initiating turn and is cancelled+joined before `TurnComplete`. + detached: bool, } /// Declared child write authority for a (deliberate) spawn. @@ -2747,6 +2754,9 @@ pub struct SubAgent { work_lifecycle: Option, input_tx: Option>, task_handle: Option>, + /// Independently durable across parent-turn cancellation when true. + /// Default direct spawns are turn-owned (`false`). + detached: bool, } impl SubAgent { @@ -2792,6 +2802,7 @@ impl SubAgent { work_lifecycle: None, input_tx: Some(input_tx), task_handle: None, + detached: false, } } @@ -3906,6 +3917,9 @@ impl SubAgentManager { work_lifecycle: None, input_tx: None, task_handle: None, + // Restored records have no live task; treat as detached so a + // later turn-end join cannot mistake them for turn-owned work. + detached: true, }; self.agents.insert(persisted.id, agent); } @@ -4738,6 +4752,45 @@ impl SubAgentManager { self.get_result(&agent_id) } + /// Cancel every turn-owned (non-detached) running child and await its task + /// handle. Detached children keep their own tokens and are left alone. + /// + /// The engine calls this before emitting `TurnComplete` so foreground + /// children cannot outlive the turn that owns them. + pub async fn cancel_and_join_turn_owned_agents(&mut self) { + let turn_owned: Vec = self + .agents + .iter() + .filter(|(_, agent)| { + !agent.detached + && agent.status == SubAgentStatus::Running + && !agent.completion_claimed + }) + .map(|(id, _)| id.clone()) + .collect(); + + let mut join_handles = Vec::new(); + for agent_id in turn_owned { + let Some(agent) = self.agents.get_mut(&agent_id) else { + continue; + }; + if let Some(handle) = agent.task_handle.take() { + handle.abort(); + join_handles.push(handle); + } + let mut terminal = agent.snapshot(); + terminal.status = SubAgentStatus::Cancelled; + terminal.result = Some("Cancelled because the owning parent turn ended.".to_string()); + terminal.needs_input = None; + // Handle already taken above; abort_task=false avoids a second take. + let _ = self.finish_terminal_result(&agent_id, terminal, false, true); + } + + for handle in join_handles { + let _ = handle.await; + } + } + /// Queue parent mail without waking the child (`agents/message`). pub fn queue_parent_message( &mut self, @@ -5035,8 +5088,8 @@ impl SubAgentManager { child_route, ) }; - // Resume runs at child depth with a detached cancellation token, the - // same seam a fresh spawn uses; fail closed on the depth ceiling. + // Resume runs at child depth with a detached cancellation token so the + // continuation survives the parent turn that requested the resume. // Checked on the parent runtime before derivation, matching the // fresh-spawn order (would_exceed_depth at the spawn seam). if runtime.would_exceed_depth() { @@ -5065,6 +5118,7 @@ impl SubAgentManager { .unwrap_or(false), claim_pre_namespaced: claim.is_some(), preserve_runtime_profile: preserved_profile, + detached: true, ..Default::default() }; let resumed = self.spawn_background_with_assignment_options( @@ -5525,6 +5579,7 @@ impl SubAgentManager { agent.session_name = name.to_string(); } agent.fork_context = options.fork_context; + agent.detached = options.detached; let agent_id = agent.id.clone(); let started_at = agent.started_at; let tool_profile = match tools.clone() { @@ -7083,11 +7138,44 @@ enum AgentToolAction { } fn parse_agent_tool_action(input: &Value) -> Result { + parse_agent_tool_action_with_policy(input, AgentActionPolicy::RequireAction) +} + +/// Stored/programmatic legacy parsing: a missing `action` still means Start. +/// Live model-facing calls must use [`parse_agent_tool_action`]. +#[cfg(test)] +fn parse_agent_tool_action_legacy(input: &Value) -> Result { + parse_agent_tool_action_with_policy(input, AgentActionPolicy::LegacyMissingMeansStart) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentActionPolicy { + RequireAction, + LegacyMissingMeansStart, +} + +fn parse_agent_tool_action_with_policy( + input: &Value, + policy: AgentActionPolicy, +) -> Result { let Some(action) = optional_input_str(input, &["action", "op"])? else { - return Ok(AgentToolAction::Start); + return match policy { + AgentActionPolicy::RequireAction => Err(ToolError::invalid_input( + "agent requires action. Use start, status, peek, message, followup, interrupt, wait, or cancel." + .to_string(), + )), + AgentActionPolicy::LegacyMissingMeansStart => Ok(AgentToolAction::Start), + }; }; match action.trim().to_ascii_lowercase().as_str() { - "" | "start" | "spawn" | "run" => Ok(AgentToolAction::Start), + "" if matches!(policy, AgentActionPolicy::LegacyMissingMeansStart) => { + Ok(AgentToolAction::Start) + } + "" => Err(ToolError::invalid_input( + "agent requires action. Use start, status, peek, message, followup, interrupt, wait, or cancel." + .to_string(), + )), + "start" | "spawn" | "run" => Ok(AgentToolAction::Start), "status" | "list" | "inspect" => Ok(AgentToolAction::Status), "peek" | "progress" => Ok(AgentToolAction::Peek), "message" | "queue_message" => Ok(AgentToolAction::Message), @@ -7205,7 +7293,11 @@ impl ToolSpec for AgentTool { "action": { "type": "string", "enum": ["start", "status", "peek", "message", "followup", "interrupt", "wait", "cancel"], - "description": "start (default) launches a background worker and returns immediately. status/peek inspect. message queues a note without waking a running child. followup delivers queued notes and wakes a running child for its next user-provenance model turn. interrupt stops the current turn while preserving the child checkpoint. wait only observes; see until. cancel permanently cancels a running child." + "description": "Required. start launches a worker owned by this turn and returns immediately (cancelled+joined when the turn ends). Pass detached=true for independently durable background work. status/peek inspect. message queues a note without waking a running child. followup delivers queued notes and wakes a running child for its next user-provenance model turn. interrupt stops the current turn while preserving the child checkpoint. wait only observes; see until. cancel permanently cancels a running child." + }, + "detached": { + "type": "boolean", + "description": "For action=start only. Default false: the child is owned by the initiating turn. Set true to keep the child running after the parent turn cancels or completes." }, "until": { "type": "string", @@ -7397,7 +7489,7 @@ impl ToolSpec for AgentTool { ] } }, - "required": [] + "required": ["action"] }) } @@ -8086,7 +8178,14 @@ async fn spawn_subagent_from_input( ))); } - let mut child_runtime = runtime.background_runtime(); + // Default direct spawns are turn-owned (`child_runtime`). Explicit + // `detached=true` keeps an independent cancel token so the child survives + // parent-turn cancellation. + let mut child_runtime = if spawn_request.detached { + runtime.background_runtime() + } else { + runtime.child_runtime() + }; let provider_binding = child_provider_binding(&runtime, profile_member.as_ref())?; child_runtime.client = provider_binding.client; child_runtime.api_config = provider_binding.api_config; @@ -8340,6 +8439,7 @@ async fn spawn_subagent_from_input( resume_from_agent_id: resume_from_agent_id.clone(), claim_pre_namespaced: false, preserve_runtime_profile: None, + detached: spawn_request.detached, }, ); let result = match result { @@ -8518,6 +8618,8 @@ pub(crate) async fn spawn_workflow_task( let mut input = json!({ "prompt": request.description, "worktree": request.worktree, + // Workflow-backed children are independently durable orchestration. + "detached": true, }); if let Some(value) = request.cwd { input["cwd"] = json!(value); @@ -10092,8 +10194,8 @@ async fn run_subagent( for _step in 0..max_steps { // Cooperative cancellation: bail if this session's token was cancelled - // while we were between steps. Top-level model-visible sub-agents use - // a detached token so parent turn cancellation does not stop them. + // while we were between steps. Default turn-owned children share the + // parent turn token; explicit detached=true children keep their own. if runtime.cancel_token.is_cancelled() { record_agent_progress( runtime, @@ -11342,6 +11444,7 @@ fn parse_spawn_request(input: &Value) -> Result { .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string); + let detached = parse_optional_bool(input, &["detached"])?.unwrap_or(false); let prompt_only_general = agent_type == FleetRole::Worker && !agent_type_explicit && profile.is_none() @@ -11380,6 +11483,7 @@ fn parse_spawn_request(input: &Value) -> Result { exact_files, coordination_contracts, resume_from, + detached, }; // A roster profile may resolve the parse-time General placeholder to a // read-only scout/reviewer or to a write-capable manager/builder. Defer diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 6d63749081..b1f1526fdd 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -15579,6 +15579,59 @@ async fn agent_peek_unchanged_within_window_returns_compact_nudge() { ); } +#[test] +fn agent_action_requires_action_for_model_facing_calls() { + let err = parse_agent_tool_action(&json!({"prompt": "look around"})) + .expect_err("missing action must fail for live model calls"); + assert!( + err.to_string().contains("requires action"), + "error should name the missing action: {err}" + ); +} + +#[test] +fn agent_action_legacy_missing_means_start() { + assert_eq!( + parse_agent_tool_action_legacy(&json!({"prompt": "look around"})) + .expect("legacy parse accepts missing action"), + AgentToolAction::Start, + ); +} + +#[test] +fn parse_spawn_request_defaults_to_turn_owned_unless_detached() { + let owned = parse_spawn_request(&json!({ + "prompt": "inspect the module", + "type": "scout" + })) + .expect("spawn should parse"); + assert!(!owned.detached, "default direct spawns are turn-owned"); + + let detached = parse_spawn_request(&json!({ + "prompt": "inspect the module", + "type": "scout", + "detached": true + })) + .expect("detached spawn should parse"); + assert!(detached.detached); +} + +#[test] +fn turn_owned_children_cancel_with_parent_while_detached_survive() { + let parent = stub_runtime(); + let owned = parent.child_runtime(); + let detached = parent.background_runtime(); + parent.cancel_token.cancel(); + assert!( + owned.cancel_token.is_cancelled(), + "turn-owned children share the parent cancel token" + ); + assert!( + !detached.cancel_token.is_cancelled(), + "explicit detached children keep an independent token" + ); +} + #[test] fn agent_action_parses_wait_aliases() { for alias in ["wait", "join", "await", "block"] { diff --git a/crates/tui/tests/runtime_web_client.test.mjs b/crates/tui/tests/runtime_web_client.test.mjs index 0d2c8137c0..6b88409470 100644 --- a/crates/tui/tests/runtime_web_client.test.mjs +++ b/crates/tui/tests/runtime_web_client.test.mjs @@ -6,16 +6,24 @@ import { STREAM_EVENT_NAMES, applyRuntimeEvent, applySnapshot, + captureTranscriptChrome, + collectUserInputAnswers, + compactFailureSummary, createThreadState, eventStreamUrl, formatRuntimeProvenance, + looksLikeMcpOrToolFailure, modeLabel, + refusalMessage, renderRuntimeProvenance, + resolveUserInputTarget, restoreDraft, + restoreTranscriptChrome, runtimeEventContinuity, saveDraft, setSafeText, snapshotThenSubscribe, + threadTarget, } from "../src/runtime_web/app.mjs"; function snapshot(threadId = "thread-a", latestSeq = 7) { @@ -585,3 +593,136 @@ test("renders hostile Runtime text only through the textContent sink", async () assert.equal(source.includes("local" + "Storage"), false); assert.equal(source.includes("session" + "Storage"), false); }); + +test("SSE gap flag clears only after snapshot and resubscription both succeed", async () => { + const state = createThreadState("thread-a"); + applySnapshot(state, snapshot("thread-a", 7)); + let streamGap = true; + let subscribeCalls = 0; + + const recovered = await snapshotThenSubscribe({ + state, + threadId: "thread-a", + loadSnapshot: async () => snapshot("thread-a", 15), + subscribe: () => { + subscribeCalls += 1; + }, + }); + assert.equal(recovered, true); + assert.equal(subscribeCalls, 1); + // Mirrors recoverProjection: clear only after both steps succeed. + if (recovered) streamGap = false; + assert.equal(streamGap, false); + + streamGap = true; + const failed = await snapshotThenSubscribe({ + state, + threadId: "thread-a", + loadSnapshot: async () => ({ thread: { id: "other" }, latest_seq: 1 }), + subscribe: () => { + subscribeCalls += 1; + }, + }); + assert.equal(failed, false); + assert.equal(subscribeCalls, 1); + if (failed) streamGap = false; + assert.equal(streamGap, true); +}); + +test("stream re-render restores open disclosures without losing chrome", () => { + const openDetails = [{ getAttribute: () => "item-reason" }]; + const restored = { open: false }; + const stubRoot = { + querySelectorAll: (sel) => (sel.includes("details") ? openDetails : []), + querySelector: () => restored, + ownerDocument: { activeElement: null }, + contains: () => false, + }; + const chrome = captureTranscriptChrome(stubRoot); + assert.deepEqual(chrome.openIds, ["item-reason"]); + restoreTranscriptChrome(stubRoot, chrome); + assert.equal(restored.open, true); +}); + +test("transcript is not a polite live region; streaming bodies mute announcements", async () => { + const html = await readFile(new URL("../src/runtime_web/index.html", import.meta.url), "utf8"); + assert.match(html, /id="status-banner"[^>]*aria-live="polite"/); + assert.doesNotMatch( + html, + /id="transcript"[^>]*aria-live="/, + ); + const source = await readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"); + assert.match(source, /aria-live", "off"/); + assert.match(source, /data-live-body/); +}); + +test("user-input Other is always offered with exact single-select cardinality and thread binding", () => { + const checked = { checked: true }; + const unchecked = { checked: false }; + const other = { value: "custom" }; + const ok = collectUserInputAnswers([ + { + question: { id: "q1", multi_select: false, header: "Pick" }, + controls: [{ input: unchecked, label: "A", value: "A" }], + other, + }, + ]); + assert.equal(ok.ok, true); + assert.deepEqual(ok.answers, [{ id: "q1", label: "Other", value: "custom" }]); + + const tooMany = collectUserInputAnswers([ + { + question: { id: "q1", multi_select: false, header: "Pick" }, + controls: [{ input: checked, label: "A", value: "A" }], + other, + }, + ]); + assert.equal(tooMany.ok, false); + assert.match(tooMany.reason, /exactly one/); + + const state = createThreadState("thread-a"); + applySnapshot(state, { + ...snapshot(), + pending_user_inputs: [{ id: "input-1", turn_id: "turn-1", request: { questions: [] } }], + }); + const bound = resolveUserInputTarget("input-1", threadTarget("thread-a"), state); + assert.equal(bound.ok, true); + assert.equal(bound.threadId, "thread-a"); + const stale = resolveUserInputTarget("input-1", threadTarget("thread-b"), state); + assert.equal(stale.ok, false); + assert.equal(refusalMessage(stale.reason), "That thread is no longer the selected one — nothing was sent."); +}); + +test("long MCP failures stay compact until expanded", () => { + const detail = `MCP server error\n${"x".repeat(400)}\ntraceback: boom`; + assert.equal(looksLikeMcpOrToolFailure("tool failed", detail), true); + const summary = compactFailureSummary("tool failed", detail, 40); + assert.ok(summary.length <= 41); + assert.match(summary, /…$/); +}); + +test("mobile drawer uses modal semantics with Escape, inert, and coarse targets", async () => { + const [source, styles] = await Promise.all([ + readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"), + readFile(new URL("../src/runtime_web/styles.css", import.meta.url), "utf8"), + ]); + assert.match(source, /aria-modal/); + assert.match(source, /session\.inert/); + assert.match(source, /Escape/); + assert.match(source, /visualViewport/); + assert.match(styles, /@media \(pointer: coarse\)/); + assert.match(styles, /min-height:\s*44px/); + assert.match(styles, /--vv-height/); +}); + +test("gap recovery clears streamGap only on successful snapshotThenSubscribe", async () => { + const source = await readFile(new URL("../src/runtime_web/app.mjs", import.meta.url), "utf8"); + assert.match(source, /app\.streamGap = true/); + // Success path inside recoverProjection must clear the flag after subscribe. + assert.match( + source, + /if \(!subscribed\) return;\s*\/\/ Gap state clears only after both[\s\S]*?app\.streamGap = false/m, + ); + // Failure path must not clear the flag. + assert.match(source, /Leave streamGap true/); +}); diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 95bd3efa8e..0f0b15fa45 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -35,8 +35,9 @@ filtered out of a child's catalog only when the depth budget is spent — grandchildren. The removed `agent_open`/`agent_eval`/`agent_close` lifecycle tools are gone from every registry, parent and child alike. -`agent` launches detached background work: cancelling the parent turn stops the -parent wait path, but it does not kill already-opened child runs. +`agent` launches turn-owned work by default: cancelling the parent turn cancels +and joins foreground children before `TurnComplete`. Pass `detached=true` for +independently durable background children that survive the parent turn. This doc covers the role taxonomy and current compatibility controls. The active orchestration surface is `agent`; see the sub-agent guidance in diff --git a/docs/WEB.md b/docs/WEB.md index 3b5a3f6338..d9206058e3 100644 --- a/docs/WEB.md +++ b/docs/WEB.md @@ -38,7 +38,7 @@ weaken the configured approval and sandbox policies. ## Authentication boundary -The browser-launch URL contains a random, short-lived, one-time bootstrap +The browser-launch URL contains a random, ten-minute, one-time bootstrap capability. It never contains the Runtime bearer token. A loopback request exchanges the capability for an `HttpOnly`, `SameSite=Strict`, process-local session cookie and immediately invalidates the capability. @@ -68,9 +68,10 @@ before operating either one, especially before selecting a non-loopback bind. ## Troubleshooting - If port `7878` is occupied, pass an unused `--port` value. -- If the browser cannot be opened, the command exits with an error rather than - printing or leaving a reusable bootstrap capability behind. Check the - operating system's default-browser setup, then start `codewhale web` again. +- If the browser cannot be opened, the Runtime keeps serving and prints a + single-use bootstrap URL (valid for ten minutes) for manual recovery. Open + that URL in any local browser; do not share it. Check the operating system's + default-browser setup if you want auto-open next time. - If the page loads but a provider is unavailable, inspect `codewhale doctor` and `/provider`; the web command does not configure or move provider credentials.