From e3a121d081285a4e3c20a5a303859e32297195bd Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 09:59:25 +0500 Subject: [PATCH] Generate lane: settle the Tune state without knobs, and hide the live bar on request Two things the maintainer hit testing the lane. The Tune chip spun forever after a generation whose variants declared no knobs (the lane's default). The overlay flips the parameter phase to pending at Go and only settled it when the wrapper mounted; the page reloads on the JSX write, the resumed session restores "pending" from its cache with the variants already mounted, and the agent's done reply never re-checked. Now the done reply completes the phase once every variant is mounted, and a resume with a pending state asks the helper's session record whether that generation already finished. A generation with no knobs shows no chip; one with knobs shows them. `live-generate --no-live-bar` (body `hideLiveBar: true`, forwarded on the agent_target payload) keeps the helper's global bar hidden for the session it starts; the variant controls still show, the choice survives a reload through the session cache, and the bar returns the moment that session ends on any path. generate.md passes the flag. Verified in a real Chromium tab: no chip before and after a reload, bar hidden through the reload, bar back after the accept. Rust and protocol cases for the flag, a CLI parse test, contract pins for both fixes. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 23 +++++++ crates/live/src/live_generate.rs | 27 +++++++- crates/live/src/live_server.rs | 8 +++ docs/CLI-CONTRACT.md | 4 +- skill/reference/generate.md | 4 +- skill/scripts/live-browser.js | 65 +++++++++++++++++++ tests/live-agent-target.test.mjs | 32 +++++++++ tests/live-browser-source.test.mjs | 39 +++++++++++ .../golden/live-generate-local-verdicts.json | 2 +- 9 files changed, 197 insertions(+), 7 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 63b796820..7070abd23 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -605,3 +605,26 @@ fn agent_target_refuses_a_generate_event_for_a_target_it_never_held() { let (status, _) = post_json(s.port, "/events", plain); assert_eq!(status, 200); } + +#[test] +fn agent_target_forwards_the_hidden_bar_request_to_the_overlay() { + let s = Server::start("hide-bar"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({ "hideLiveBar": true })); + let pushed = a.next(|m| m["type"] == "agent_target"); + assert_eq!(pushed["hideLiveBar"], serde_json::json!(true), "{pushed}"); + let target_id = pushed["targetId"].as_str().unwrap().to_string(); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + held.join().unwrap(); + // Absent by default, and anything but a boolean is refused. + let held = s.hold(serde_json::json!({})); + let pushed = a.next(|m| m["type"] == "agent_target"); + assert!(pushed.get("hideLiveBar").is_none(), "{pushed}"); + let target_id = pushed["targetId"].as_str().unwrap().to_string(); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccde" })); + held.join().unwrap(); + let (status, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({ "hideLiveBar": "yes" }))); + assert_eq!(status, 400, "{body}"); + assert_eq!(body["error"], serde_json::json!("agent_target: hideLiveBar must be a boolean")); +} diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index 222e8a22d..529c820ef 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -17,7 +17,7 @@ use impeccable_common::Io; use serde_json::{json, Map, Value}; use std::time::{Duration, Instant}; -const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] +const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] [--no-live-bar] Flags: --selector required; resolved with document.querySelectorAll @@ -39,11 +39,13 @@ const REQUEST_TIMEOUT_MS: u64 = 20_000; struct Flags { values: Map, dry_run: bool, + no_live_bar: bool, } fn parse_flags(argv: &[String]) -> Result { let mut values = Map::new(); let mut dry_run = false; + let mut no_live_bar = false; let mut i = 0; while i < argv.len() { let arg = &argv[i]; @@ -57,6 +59,11 @@ fn parse_flags(argv: &[String]) -> Result { i += 1; continue; } + if key == "no-live-bar" { + no_live_bar = true; + i += 1; + continue; + } match argv.get(i + 1) { Some(v) if !v.starts_with("--") => { values.insert(key.to_string(), json!(v)); @@ -67,7 +74,11 @@ fn parse_flags(argv: &[String]) -> Result { } } } - Ok(Flags { values, dry_run }) + Ok(Flags { + values, + dry_run, + no_live_bar, + }) } fn flag<'a>(flags: &'a Flags, key: &str) -> Option<&'a str> { @@ -286,6 +297,9 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if flags.dry_run { body.insert("dryRun".into(), json!(true)); } + if flags.no_live_bar { + body.insert("hideLiveBar".into(), json!(true)); + } let url = format!("http://127.0.0.1:{}/agent-target", port); let agent = ureq::AgentBuilder::new() @@ -366,6 +380,15 @@ mod tests { m } + #[test] + fn no_live_bar_is_a_boolean_flag() { + let flags = parse_flags(&["--selector".to_string(), "h1".to_string(), "--no-live-bar".to_string(), "--count".to_string(), "3".to_string()]).unwrap(); + assert!(flags.no_live_bar); + assert_eq!(flags.values.get("selector").and_then(Value::as_str), Some("h1")); + assert_eq!(flags.values.get("count").and_then(Value::as_str), Some("3")); + assert!(!parse_flags(&["--selector".to_string(), "h1".to_string()]).unwrap().no_live_bar); + } + #[test] fn timeout_instructions_promise_no_stray_session() { let mut m = Map::new(); diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 03dfa6156..efd58af2d 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -2706,6 +2706,11 @@ fn validate_agent_target_request(msg: &Value) -> Option { return Some("agent_target: dryRun must be a boolean".into()); } } + if let Some(hide) = msg.get("hideLiveBar") { + if !hide.is_boolean() { + return Some("agent_target: hideLiveBar must be a boolean".into()); + } + } None } @@ -2771,6 +2776,9 @@ fn handle_agent_target_post( if msg.get("dryRun").and_then(Value::as_bool) == Some(true) { payload.insert("dryRun".into(), json!(true)); } + if msg.get("hideLiveBar").and_then(Value::as_bool) == Some(true) { + payload.insert("hideLiveBar".into(), json!(true)); + } let (target_id, rx) = st.register_agent_target(payload); drop(st); // Registered and broadcast; the response now parks, so let the claims diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 869e42b24..ff357bd26 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1479,7 +1479,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-repair-decision` (token body or query) | 401 | see 10 | | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | -| `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | +| `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean` / `agent_target: hideLiveBar must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | | `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. An accepted generate event carrying the envelope is journaled and queued with `origin: "agent"`, and `live-poll` renders that event's `_instructions` as the fast path (identity from the event's `element.computedStyles` / `cssCustomProperties` / `parentContext`, the action's three dimensions, no parameter knobs unless the prompt asks, one edit, reply done) instead of the interactive planning pointer and the action-reference read. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | @@ -1799,7 +1799,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit #### `live-generate.mjs` -> `impeccable live-generate` - **Invoked from**: `skill/reference/generate.md` (the `generate` command), after `impeccable live` booted the helper and the agent opened the app page: `impeccable live-generate --selector "section.pricing" --action bolder --count 3`. -- **Args**: `--selector ` (required), `--text `, `--index ` (1-based), `--action ` (default `impeccable`), `--count ` (default 3, 1-8), `--prompt `, `--dry-run`, `--wait-for-browser `, `--target ` (consumed by `enterLiveRoot`), `--help`. A flag without a value → stdout `{"ok":false,"error":"missing_flag_value","flag":"--x"}`, exit 1. +- **Args**: `--selector ` (required), `--text `, `--index ` (1-based), `--action ` (default `impeccable`), `--count ` (default 3, 1-8), `--prompt `, `--dry-run`, `--wait-for-browser `, `--no-live-bar` (body `hideLiveBar:true`; the overlay hides the helper's global bar for the session it starts and restores it when that session ends, persisting the choice across reloads in its session cache), `--target ` (consumed by `enterLiveRoot`), `--help`. A flag without a value → stdout `{"ok":false,"error":"missing_flag_value","flag":"--x"}`, exit 1. - **Env**: `IMPECCABLE_SELF` (how the boot and poll verbs are spelled in `_instructions`). - **Behavior**: `enterLiveRoot`; local verdicts first, each pretty-printed JSON on stdout with `_instructions`, exit 1: `selector_required`, `invalid_action` (+`action`, `validActions`), `invalid_count` (+`count`), `invalid_index` (+`index`), `invalid_wait` (+`wait`); no `server.json` (or one without port/token) → `server_not_running`. With `--wait-for-browser`, `GET /status` once a second until `connectedClients > 0` or the budget ends (`no_browser_connected` + `waitedMs`); an unanswered `/status` → `server_unreachable`. Then `POST /agent-target` with `{token, selector, action, count, text?, index?, prompt?, dryRun?}` under a 20 s client cap: a transport timeout → `request_timeout` (+`detail`, browser_timeout instructions), any other transport failure → `server_unreachable` (+`detail`); a non-2xx answer → `{ok:false, error:>, ...body}`; an unparseable body → `bad_server_response` (+`status`). A 2xx answer is printed as received plus `_instructions` for `ok` (dry run or started session, naming `impeccable live-poll`), `no_browser_connected`, `browser_timeout`, `invalid_selector`, `no_match` (wording depends on `rawMatchCount`), `ambiguous`, `index_out_of_range`, `busy`, `go_failed`, `server_stopping`; exit 0 when `ok:true`, else 1. `_instructions` are regenerated locally from the verdict, never taken from the wire. - **Tests**: `tests/oracle/cases/live-generate.mjs` (local verdicts, no-browser), `tests/live-agent-target.test.mjs` (protocol matrix against the binary), `crates/cli/tests/agent_target.rs`, `tests/live-e2e.test.mjs` (`agentTargetScenario`). diff --git a/skill/reference/generate.md b/skill/reference/generate.md index 50dc860a7..1b2f991cc 100644 --- a/skill/reference/generate.md +++ b/skill/reference/generate.md @@ -61,10 +61,10 @@ Done when the boot printed `"ok": true` and a page is open. You do not need to r One command. Derive the selector from what the user said and what you already know of the project: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain. ```bash -{{scripts_path}}/impeccable live-generate --selector "#pricing" --action bolder --count 3 +{{scripts_path}}/impeccable live-generate --selector "#pricing" --action bolder --count 3 --no-live-bar ``` -Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser `. +Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser `, `--no-live-bar` (always pass it: the helper's bottom bar stays hidden for this session, and only the variant controls show; the bar returns when the session ends). Every verdict carries `_instructions`; follow them over your recollection of this file. Two deserve naming: diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 5a0b9b7cc..96e00abe6 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -6182,6 +6182,7 @@ clearSession(); clearHandled(); resetSessionFileMeta(); + releaseHiddenLiveBar(currentSessionId); currentSessionId = null; parameterGenerationState = 'idle'; parameterReadyAnnouncedSession = null; @@ -7215,6 +7216,26 @@ // actOnAgentTarget around its handleGo call, read once by handleGo. let agentTargetForGo = null; + // The session whose agent target asked for the helper's bottom bar to + // stay out of the way (`live-generate --no-live-bar`): the variant + // controls still show, the global bar does not, until that session ends. + let agentTargetHideBarSession = null; + + function setLiveBarHidden(hidden) { + if (!globalBarEl) return; + globalBarEl.style.display = hidden ? 'none' : ''; + } + + // The bar comes back the moment the session that hid it is over, whichever + // path ends it (cleanup, an accept's completion, a handled or foreign + // session reset): every site that clears currentSessionId releases it. + function releaseHiddenLiveBar(sessionId) { + if (!agentTargetHideBarSession) return; + if (sessionId && sessionId !== agentTargetHideBarSession) return; + agentTargetHideBarSession = null; + setLiveBarHidden(false); + } + function claimAgentTarget(targetId, report) { return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, { method: 'POST', @@ -7459,6 +7480,11 @@ handleGo(); agentTargetForGo = null; if (state === 'GENERATING' && currentSessionId) { + if (msg.hideLiveBar === true) { + agentTargetHideBarSession = currentSessionId; + setLiveBarHidden(true); + saveSession(); + } reply({ ok: true, matchCount: resolved.matchCount, @@ -7573,6 +7599,11 @@ disableInlineEdit(); refreshParamsPanel(); } + // The done reply is the agent's last word on this generation: + // with every variant mounted and no knobs declared, the Tune + // chip must stop spinning. A reload between the mount and this + // reply restored the pending state from the cache. + completeParameterGenerationIfReady(); break; } // Source fallback when HMR did not land variants in this tab. @@ -9325,6 +9356,7 @@ void main() { selectedElement = null; hoveredElement = null; pagePickSkipClick = false; + releaseHiddenLiveBar(currentSessionId); currentSessionId = null; parameterGenerationState = 'idle'; parameterReadyAnnouncedSession = null; @@ -9419,6 +9451,10 @@ void main() { } if (saved.parameterState) parameterGenerationState = saved.parameterState; if (saved.generationPhase) generationPhase = saved.generationPhase; + if (saved.hideLiveBar === true && saved.id) { + agentTargetHideBarSession = saved.id; + setLiveBarHidden(true); + } } function normalizePagePath(value) { @@ -9623,6 +9659,7 @@ void main() { pageUrl: location.pathname, paramValues: { ...paramsCurrentValues }, parameterState: parameterGenerationState, + hideLiveBar: agentTargetHideBarSession === currentSessionId ? true : undefined, insertPlaceholder: insertPlaceholderSnapshot || undefined, pickedAnchor: pickedAnchorSnapshot || undefined, pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined, @@ -9670,6 +9707,7 @@ void main() { const instantChrome = options?.instantChrome === true; const cleanupSessionId = currentSessionId; const cleanupRevision = liveInteractionRevision; + releaseHiddenLiveBar(cleanupSessionId); clearMountErrorCard(); lastReportedMountFailure = null; if (svelteComponentSession?.sessionId === cleanupSessionId) { @@ -9746,6 +9784,7 @@ void main() { selectedElement = null; hoveredElement = null; pagePickSkipClick = false; + releaseHiddenLiveBar(currentSessionId); currentSessionId = null; parameterGenerationState = 'idle'; parameterReadyAnnouncedSession = null; @@ -10006,6 +10045,14 @@ void main() { const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING'; + // A reload between the variants mounting and the agent's done reply + // restores a pending Tune state from the cache; the helper knows whether + // that generation already finished. + if (arrivedVariants >= expectedVariants && expectedVariants > 0 + && (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) { + settleParameterStateFromHelper(sessionId); + } + // Find the visible variant's content element for highlight positioning. const isInsert = wrapper.dataset.impeccableMode === 'insert'; const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null; @@ -11470,6 +11517,21 @@ void main() { } } + // After a resume the cache may say the Tune knobs are still coming while + // the agent already replied done before the reload. The helper's session + // record settles it; otherwise the done reply on SSE does. + function settleParameterStateFromHelper(sessionId) { + fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data || sessionId !== currentSessionId) return; + const session = (data.activeSessions || []).find((s) => s && s.id === sessionId); + if (!session) return; + if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady(); + }) + .catch(() => { /* the done reply on SSE settles it otherwise */ }); + } + function fetchAgentPollingStatus() { fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' }) .then((res) => (res.ok ? res.json() : null)) @@ -11924,6 +11986,8 @@ void main() { // Listen for detection results AND ready signal window.addEventListener('message', onDetectMessage); updateGlobalBarState(); + // A session resumed before the bar existed may have asked for it to stay hidden. + if (agentTargetHideBarSession && agentTargetHideBarSession === currentSessionId) setLiveBarHidden(true); } function updateGlobalBarState() { @@ -12126,6 +12190,7 @@ void main() { // not refuse every target the next connection hears. busyDeclinedTargets.clear(); agentTargetsSeen.clear(); + agentTargetHideBarSession = null; stopAgentStatusPoll(); hideAgentPollTooltip(); if (agentPollTooltipEl) { diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 7b4de6b54..989d49479 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -853,6 +853,27 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA assert.equal((await event(null)).status, 200); }); + it('forwards the hidden-bar request to the overlay, and refuses a non-boolean', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + try { + await tabA.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, hideLiveBar: true, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + assert.equal(pushed.hideLiveBar, true, 'the overlay is told to keep the bottom bar out of the way'); + await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, ok: true, sessionId: 'aabbccdd' }); + await (await held).json(); + const refused = await postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, hideLiveBar: 'yes', + }); + assert.equal(refused.status, 400); + assert.equal((await refused.json()).error, 'agent_target: hideLiveBar must be a boolean'); + } finally { + tabA.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); @@ -1055,6 +1076,17 @@ describe('live-generate CLI local failure modes', { skip: ENGINE_BIN ? false : E } } + it('accepts --no-live-bar as a boolean flag', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-')); + try { + const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder', '--no-live-bar']); + assert.equal(code, 1); + assert.equal(json.error, 'server_not_running', 'the flag parses; the verdict is about the missing helper, not the flag'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + it('fails with server_not_running when no live server is recorded', () => { const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-')); try { diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index cd97352f4..6f0d6a24b 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -823,6 +823,29 @@ describe('live-browser source contracts', () => { ); }); + it('settles the Tune knob state when the agent is done, even across a reload', () => { + // A generation with no knobs (the generate lane's default) left the Tune + // chip spinning: the done reply never completed the parameter phase when + // the variants had already mounted, and a reload restored the pending + // state from the cache with nothing left to complete it. + const doneCase = SOURCE.match(/case 'done':[\s\S]*?case 'complete':/)?.[0] || ''; + assert.match( + doneCase, + /if \(arrivedVariants >= expectedVariants && expectedVariants > 0\) \{[\s\S]*?completeParameterGenerationIfReady\(\);\s*break;/, + 'the done reply completes the parameter phase once every variant is mounted', + ); + assert.match( + SOURCE, + /const resumedState = arrivedVariants > 0 \? 'CYCLING' : 'GENERATING';[\s\S]{0,600}?settleParameterStateFromHelper\(sessionId\);/, + 'a resume with a pending Tune state asks the helper whether the generation already finished', + ); + assert.match( + SOURCE, + /function settleParameterStateFromHelper\(sessionId\) \{[\s\S]{0,900}?session\.generationCompletedAt \|\| session\.generationPhase === 'completed'\) completeParameterGenerationIfReady\(\);/, + 'the helper\'s session record is what settles it', + ); + }); + it('re-claims busy-declined agent targets only while the overlay can still serve them', () => { const teardownSource = SOURCE.match(/function teardown\(\) \{[\s\S]*?\n \}/)?.[0] || ''; const clearAt = teardownSource.indexOf('busyDeclinedTargets.clear();'); @@ -880,6 +903,22 @@ describe('live-browser source contracts', () => { /body\.error === 'agent_target_already_served' && msg\.type === 'generate'\s*&& msg\.id && msg\.id === currentSessionId\) \{\s*abandonSupersededGo\(msg\.id\);\s*return null;/, 'a Go the helper refused as already served drops this page\'s local session instead of leaving it generating for nothing', ); + assert.match( + SOURCE, + /if \(msg\.hideLiveBar === true\) \{\s*agentTargetHideBarSession = currentSessionId;\s*setLiveBarHidden\(true\);\s*saveSession\(\);/, + 'an agent target that asks for it hides the global bar for the session it starts, and remembers that in the session cache', + ); + assert.match(SOURCE, /releaseHiddenLiveBar\(cleanupSessionId\);/, 'cleanup releases the hidden bar for the session it ends'); + assert.equal( + (SOURCE.match(/releaseHiddenLiveBar\(currentSessionId\);\n\s*currentSessionId = null;/g) || []).length, + 3, + 'every site that clears the session id releases the bar first, so an accept completion brings it back too', + ); + assert.match( + SOURCE, + /if \(saved\.hideLiveBar === true && saved\.id\) \{\s*agentTargetHideBarSession = saved\.id;\s*setLiveBarHidden\(true\);/, + 'a reload keeps the bar hidden for a session that asked for it', + ); assert.match( SOURCE, /if \(claim\.granted\) \{ noteAgentTarget\(msg\.targetId, 'acting'\); actOnAgentTarget\(msg\); return; \}/, diff --git a/tests/oracle/golden/live-generate-local-verdicts.json b/tests/oracle/golden/live-generate-local-verdicts.json index b7f0170d0..351e2aa61 100644 --- a/tests/oracle/golden/live-generate-local-verdicts.json +++ b/tests/oracle/golden/live-generate-local-verdicts.json @@ -1,7 +1,7 @@ { "steps": [ { - "stdout": "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ]\n\nFlags:\n --selector required; resolved with document.querySelectorAll\n --text optional; keeps only matches whose textContent contains it\n --index optional; 1-based pick among the remaining matches\n --action optional; one of the live action vocabulary (default: impeccable)\n --count optional; variants to request, 1-8 (default: 3)\n --prompt optional; freeform direction, same as typing before Go\n --dry-run optional; resolve and report without starting anything\n --wait-for-browser optional; poll the helper until a page with the\n overlay connects (or the budget runs out) before sending\n the target.\n\n", + "stdout": "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] [--no-live-bar]\n\nFlags:\n --selector required; resolved with document.querySelectorAll\n --text optional; keeps only matches whose textContent contains it\n --index optional; 1-based pick among the remaining matches\n --action optional; one of the live action vocabulary (default: impeccable)\n --count optional; variants to request, 1-8 (default: 3)\n --prompt optional; freeform direction, same as typing before Go\n --dry-run optional; resolve and report without starting anything\n --wait-for-browser optional; poll the helper until a page with the\n overlay connects (or the budget runs out) before sending\n the target.\n\n", "stderr": "", "exit": 0, "signal": null