"""Behavioural tests that drive the ACTUAL cmdGoal() from static/commands.js via node. The source-inspection regressions in test_goal_command_webui.py assert that certain expressions/order exist inside cmdGoal's source text. They can stay green when a refactor preserves those strings but sends the wrong explicit_model_pick or consumes the pending session-model marker at the wrong time (#6705, greptile-apps P2). This file closes that gap by spawning node on the real static/commands.js, extracting cmdGoal, and driving it against a mocked browser environment (sessionStorage, S, window, api) — asserting the OBSERVABLE effects: the explicit_model_pick field on the /api/goal payload and the pending marker's survival/consumption in sessionStorage. Mirrors the approach of test_renderer_js_behaviour.py. """ import json import shutil import subprocess from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[1] COMMANDS_JS_PATH = REPO_ROOT / "static" / "commands.js" NODE = shutil.which("node") pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") _DRIVER_SRC = r""" const fs = require('fs'); const src = fs.readFileSync(process.argv[2], 'utf8'); const scenario = process.argv[3] || ''; // ---- mocked browser environment ---- const _store = new Map(); global.sessionStorage = { getItem: k => (_store.has(k) ? _store.get(k) : null), setItem: (k, v) => { _store.set(k, String(v)); }, removeItem: k => { _store.delete(k); }, }; global.window = {}; // Pending session-model marker helpers mirroring static/ui.js // (PENDING_SESSION_MODEL_PREFIX / _readPendingSessionModel / _clearPendingSessionModel). const PENDING_PREFIX = 'hermes-webui-pending-session-model:'; const MAX_AGE_MS = 10 * 60 * 1000; const _key = sid => PENDING_PREFIX + String(sid || ''); function rememberPending(sid, model, provider) { const s = String(sid || '').trim(); const value = String(model || '').trim(); if (!s || !value) return; try { sessionStorage.setItem(_key(s), JSON.stringify({ model: value, model_provider: provider ? String(provider).trim() : null, saved_at: Date.now(), })); } catch (_) {} } function readPending(sid) { const s = String(sid || '').trim(); if (!s) return null; try { const raw = sessionStorage.getItem(_key(s)); if (!raw) return null; const parsed = JSON.parse(raw); const model = String(parsed && parsed.model || '').trim(); if (!model) { sessionStorage.removeItem(_key(s)); return null; } const savedAt = Number(parsed.saved_at || 0); if (savedAt && Date.now() - savedAt > MAX_AGE_MS) { sessionStorage.removeItem(_key(s)); return null; } return { model, model_provider: parsed && parsed.model_provider ? String(parsed.model_provider) : null, }; } catch (_) { return null; } } function clearPending(sid) { const s = String(sid || '').trim(); if (!s) return; try { sessionStorage.removeItem(_key(s)); } catch (_) {} } const _readPendingSessionModel = readPending; const _clearPendingSessionModel = clearPending; // ---- command helpers the extracted cmdGoal references ---- const t = k => k; const showToast = () => {}; const renderMessages = () => {}; const clearLiveToolCards = () => {}; const appendThinking = () => {}; const setBusy = () => {}; const setComposerStatus = () => {}; const markInflight = () => {}; const saveInflightState = () => {}; const startApprovalPolling = () => {}; const startClarifyPolling = () => {}; const _fetchYoloState = () => {}; const attachLiveStream = () => {}; const renderSessionList = () => {}; const newSession = async () => {}; const $ = () => null; const INFLIGHT = {}; // ---- api mock: record every /api/goal payload, respond per scenario ---- const _apiCalls = []; let _nextResponse = () => ({}); async function api(url, opts) { _apiCalls.push({ url, body: JSON.parse(opts.body) }); return _nextResponse(); } // ---- extract cmdGoal from the real file and evaluate it ---- function extractFunc(name) { // Preserve a leading `async` keyword — dropping it would make the // extracted `await` statements a SyntaxError. const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\('); const m = re.exec(src); if (!m) throw new Error(name + ' not found'); const start = m.index; let i = src.indexOf('{', start); let depth = 1; i++; while (depth > 0 && i < src.length) { if (src[i] === '{') depth++; else if (src[i] === '}') depth--; i++; } return src.slice(start, i); } eval(extractFunc('cmdGoal')); // ---- scenario state ---- const SID = 'sid-6705-behaviour'; const S = { session: { session_id: SID, workspace: '/tmp/ws', model: 'openai/gpt-5.4', model_provider: 'openai', profile: 'default', active_stream_id: null, }, activeProfile: 'default', messages: [], toolCalls: [], activeStreamId: null, }; (async () => { const out = {}; if (scenario === 'kickoff_consumes') { // Pending pick matches the session model; server returns a real kickoff. rememberPending(SID, 'openai/gpt-5.4', 'openai'); window._defaultModel = 'gpt-4o'; window._activeProvider = 'openai'; _nextResponse = () => ({ stream_id: 's1', pending_started_at: 1, effective_model: 'openai/gpt-5.4', effective_model_provider: 'openai' }); await cmdGoal('ship it'); out.payload = _apiCalls[0].body; out.markerAfter = readPending(SID); } else if (scenario === 'control_then_kickoff') { // Control-only /goal status: server responds WITHOUT stream_id. rememberPending(SID, 'openai/gpt-5.4', 'openai'); window._defaultModel = 'gpt-4o'; window._activeProvider = 'openai'; _nextResponse = () => ({ message: 'no active goal', message_key: 'goal_no_active' }); await cmdGoal('status'); out.controlPayload = _apiCalls[0].body; out.markerAfterControl = readPending(SID); // Next real send must still carry the marker and consume it on kickoff. _nextResponse = () => ({ stream_id: 's2', pending_started_at: 1 }); await cmdGoal('ship it'); out.kickoffPayload = _apiCalls[1].body; out.markerAfterKickoff = readPending(SID); } else if (scenario === 'midflight_newer_marker_kept') { // A newer dropdown selection is recorded WHILE the request is in flight. rememberPending(SID, 'openai/gpt-5.4', 'openai'); window._defaultModel = 'gpt-4o'; window._activeProvider = 'openai'; _nextResponse = () => { rememberPending(SID, 'openai/gpt-6', 'openai'); return { stream_id: 's3', pending_started_at: 1 }; }; await cmdGoal('ship it'); out.payload = _apiCalls[0].body; out.markerAfter = readPending(SID); } else if (scenario === 'no_marker_no_pick') { // Untouched default session: no pending marker, no cross-provider pick. S.session.model = 'gpt-4o'; S.session.model_provider = 'openai'; window._defaultModel = 'gpt-4o'; window._activeProvider = 'openai'; _nextResponse = () => ({ stream_id: 's4', pending_started_at: 1 }); await cmdGoal('ship it'); out.payload = _apiCalls[0].body; out.markerAfter = readPending(SID); } else { throw new Error('unknown scenario: ' + scenario); } process.stdout.write(JSON.stringify(out)); })().catch(e => { process.stderr.write(String((e && e.stack) || e)); process.exit(1); }); """ @pytest.fixture(scope="module") def driver_path(tmp_path_factory): """Write the node driver to a tmp file (works around `node -e` arg quirks).""" p = tmp_path_factory.mktemp("goal_driver") / "driver.js" p.write_text(_DRIVER_SRC, encoding="utf-8") return str(p) def _run_scenario(driver_path, scenario): """Run cmdGoal against the real commands.js with mocked browser state.""" result = subprocess.run( [NODE, driver_path, str(COMMANDS_JS_PATH), scenario], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed for {scenario}: {result.stderr}") return json.loads(result.stdout) def test_goal_kickoff_consumes_pending_marker_after_success(driver_path): """#6703/#6705: a real kickoff carries explicit_model_pick and consumes the one-shot pending marker AFTER the successful response (r.stream_id).""" out = _run_scenario(driver_path, "kickoff_consumes") assert out["payload"].get("explicit_model_pick") is True assert out["markerAfter"] is None def test_goal_control_command_keeps_marker_and_next_send_still_picks(driver_path): """#6705: a control-only /goal (e.g. `/goal status`, no stream_id) must NOT consume the pending explicit-pick marker; the next real send still carries explicit_model_pick and only then consumes the marker.""" out = _run_scenario(driver_path, "control_then_kickoff") # Control round-trip: marker read for the payload but left intact. assert out["controlPayload"].get("explicit_model_pick") is True assert out["markerAfterControl"] is not None assert out["markerAfterControl"]["model"] == "openai/gpt-5.4" # Next real send: still carries the pick, then consumes the marker. assert out["kickoffPayload"].get("explicit_model_pick") is True assert out["markerAfterKickoff"] is None def test_goal_kickoff_keeps_newer_midflight_marker(driver_path): """#6705: a marker re-recorded while the request is in flight (newer dropdown selection) must not be clobbered by the stale consume-clear.""" out = _run_scenario(driver_path, "midflight_newer_marker_kept") assert out["payload"].get("explicit_model_pick") is True assert out["markerAfter"] is not None assert out["markerAfter"]["model"] == "openai/gpt-6" def test_goal_kickoff_without_marker_sends_no_explicit_pick(driver_path): """#6703: untouched default sessions (no pending marker, no cross-provider pick) must not send the marker at all.""" out = _run_scenario(driver_path, "no_marker_no_pick") assert "explicit_model_pick" not in out["payload"] assert out["markerAfter"] is None