From b3e0da2c130673935547f418b3235187eb227839 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 03:52:11 +0700 Subject: [PATCH 1/6] Fix Codex provider default and campaign deep links --- qa/test_macos_app_static.py | 7 ++- scripts/play_codex_actor.sh | 19 ++++--- scripts/play_codex_dm.sh | 19 ++++--- .../tests/test_codex_provider_wrapper.py | 55 ++++++++++++++++++- viewer/openworlds/app.jsx | 21 +++++-- viewer/tests/test_openworlds_static.py | 8 ++- 6 files changed, 99 insertions(+), 30 deletions(-) diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index a428837e..f53a820e 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -89,14 +89,15 @@ def test_built_app_part_b_supports_codex_provider_and_player_agent(self): self.assertIn("player_agent", harness) self.assertIn("provider", harness) - def test_codex_provider_wrappers_omit_model_arg_when_env_unset(self): + def test_codex_provider_wrappers_pin_supported_default_model(self): dm = self.read("scripts/play_codex_dm.sh") actor = self.read("scripts/play_codex_actor.sh") for script in (dm, actor): self.assertIn("WORLDOS_CODEX_MODEL", script) - self.assertIn('CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-}}"', script) - self.assertNotIn('CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-gpt-5.5}}"', script) + self.assertIn('CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-gpt-5.5}}"', script) + self.assertIn("auto|default|cli-default", script) + self.assertNotIn('CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-}}"', script) self.assertIn('MODEL_ARGS=(--model "$CODEX_MODEL")', script) self.assertIn("--ignore-user-config", script) diff --git a/scripts/play_codex_actor.sh b/scripts/play_codex_actor.sh index 40dcc07a..f60210b2 100755 --- a/scripts/play_codex_actor.sh +++ b/scripts/play_codex_actor.sh @@ -35,8 +35,8 @@ Optional: CLAWDND_PLAY_COMPANIONS CLAWDND_ACTOR_ID CLAWDND_ACTOR_ROLE - WORLDOS_CODEX_MODEL - CLAWDND_CODEX_MODEL + WORLDOS_CODEX_MODEL (default: gpt-5.5; set to auto/default/cli-default to let Codex CLI choose) + CLAWDND_CODEX_MODEL (legacy fallback) CLAWDND_STATE_ROOT EOF exit 0 @@ -198,13 +198,16 @@ if [ "$MODE" != "run" ]; then fi # codex exec intentionally ignores user config so app/provider proofs do not -# inherit local prompts or sandbox policy. Let Codex CLI choose its account -# default unless the operator explicitly pins a provider model. -CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-}}" +# inherit local prompts or sandbox policy. Pin a ChatGPT-account-supported +# provider model unless the operator explicitly selects another one. The Codex +# CLI account default can drift to a model this auth surface rejects, so app +# playability should not depend on that ambient default. +CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-gpt-5.5}}" MODEL_ARGS=() -if [ -n "${CODEX_MODEL//[[:space:]]/}" ]; then - MODEL_ARGS=(--model "$CODEX_MODEL") -fi +case "$(printf '%s' "$CODEX_MODEL" | tr '[:upper:]' '[:lower:]')" in + ""|auto|default|cli-default) ;; + *) MODEL_ARGS=(--model "$CODEX_MODEL") ;; +esac export CLAWDND_STATE_DIR="$RUN_DIR" export CLAWDND_PLAYER_MOVES="$MOVES" export CLAWDND_ACTOR_ID="${CLAWDND_ACTOR_ID:-}" diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index d6696312..2670e8ef 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -34,8 +34,8 @@ Required environment: Optional: CLAWDND_PLAY_COMPANIONS CLAWDND_PLAY_HERO - WORLDOS_CODEX_MODEL - CLAWDND_CODEX_MODEL + WORLDOS_CODEX_MODEL (default: gpt-5.5; set to auto/default/cli-default to let Codex CLI choose) + CLAWDND_CODEX_MODEL (legacy fallback) CLAWDND_STATE_ROOT EOF exit 0 @@ -239,13 +239,16 @@ if [ "$MODE" != "run" ]; then fi # codex exec intentionally ignores user config so app/provider proofs do not -# inherit local prompts or sandbox policy. Let Codex CLI choose its account -# default unless the operator explicitly pins a provider model. -CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-}}" +# inherit local prompts or sandbox policy. Pin a ChatGPT-account-supported +# provider model unless the operator explicitly selects another one. The Codex +# CLI account default can drift to a model this auth surface rejects, so app +# playability should not depend on that ambient default. +CODEX_MODEL="${WORLDOS_CODEX_MODEL:-${CLAWDND_CODEX_MODEL:-gpt-5.5}}" MODEL_ARGS=() -if [ -n "${CODEX_MODEL//[[:space:]]/}" ]; then - MODEL_ARGS=(--model "$CODEX_MODEL") -fi +case "$(printf '%s' "$CODEX_MODEL" | tr '[:upper:]' '[:lower:]')" in + ""|auto|default|cli-default) ;; + *) MODEL_ARGS=(--model "$CODEX_MODEL") ;; +esac export CLAWDND_STATE_DIR="$RUN_DIR" export WORLDOS_STATE_DIR="$RUN_DIR" export CLAWDND_RULES_OFFLINE=1 diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index ba3dc57f..9a07a1d2 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -293,7 +293,7 @@ def test_codex_dm_wrapper_move_prompt_does_not_restate_opening_persist_ban(): assert "do not call persist_beat during the opening turn" not in move_prompt -def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): +def test_codex_dm_wrapper_run_pins_supported_default_model_with_fake_codex(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() fake_codex = bin_dir / "codex" @@ -308,8 +308,11 @@ def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): shift 2 ;; --model) - echo "unexpected model arg" >&2 - exit 7 + [ "$2" = "gpt-5.5" ] || { + echo "unexpected model: $2" >&2 + exit 7 + } + shift 2 ;; *) shift @@ -338,6 +341,52 @@ def test_codex_dm_wrapper_run_allows_unset_model_with_fake_codex(tmp_path): assert "Opening narration from fake Codex." in chat.read_text(encoding="utf-8") +def test_codex_dm_wrapper_can_delegate_to_cli_default_with_fake_codex(tmp_path): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_codex = bin_dir / "codex" + fake_codex.write_text( + """#!/usr/bin/env bash +set -euo pipefail +last="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message) + last="$2" + shift 2 + ;; + --model) + echo "unexpected model arg" >&2 + exit 7 + ;; + *) + shift + ;; + esac +done +cat >/dev/null +printf 'Opening narration from fake Codex.' > "$last" +printf '{"type":"result","result":"Opening narration from fake Codex."}\n' +""", + encoding="utf-8", + ) + fake_codex.chmod(0o755) + env = _env( + tmp_path, + PATH=f"{bin_dir}:{os.environ.get('PATH', '')}", + CLAWDND_RUN_ID="fake-codex-cli-default", + CLAWDND_PLAY_PORT="8799", + WORLDOS_CODEX_MODEL="auto", + CLAWDND_PLAY_HERO=json.dumps({"canon": True, "name": "Abby"}), + ) + + result = _run_dm([], env, timeout=20) + + assert result.returncode == 0, result.stdout + result.stderr + chat = tmp_path / "fake-codex-cli-default" / "chat.jsonl" + assert "Opening narration from fake Codex." in chat.read_text(encoding="utf-8") + + def test_codex_dm_wrapper_processes_moves_submitted_during_opening(tmp_path): bin_dir = tmp_path / "bin" bin_dir.mkdir() diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 1c818a3b..5147bf4b 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -80,6 +80,11 @@ function openWorldsPlayerChronicle(c) { return Boolean(c?.canResume || c?.current); } +function openWorldsCampaignMatches(c, campaignRef) { + if (!campaignRef) return false; + return c?.id === campaignRef || c?.campaign_id === campaignRef; +} + const OPENWORLDS_VALID_SCREENS = new Set([ "launcher", "roster", "table", "combat", "dialogue", "map", "character", "inventory", "forge", "relations", "journal", "bestiary", "acts", "merchant", "create", @@ -245,7 +250,7 @@ function nextLogSeq() { __logSeq += 1; return __logSeq; } function useLiveSession(state) { const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const activeCampaign = - campaigns.find((c) => c.id === state?.activeCampaign) || + campaigns.find((c) => openWorldsCampaignMatches(c, state?.activeCampaign)) || campaigns[0] || {}; const campaignId = activeCampaign.campaign_id || state?.activeCampaign || activeCampaign.id || ""; @@ -714,11 +719,15 @@ function App() { const nextCampaigns = Array.isArray(payload?.campaigns) ? payload.campaigns : []; setState((s) => { const requestedCampaign = requestedCampaignRef.current; - const requestedStillExists = requestedCampaign && nextCampaigns.some((c) => c.id === requestedCampaign); + const requestedEntry = requestedCampaign + ? nextCampaigns.find((c) => openWorldsCampaignMatches(c, requestedCampaign)) + : null; + const requestedStillExists = Boolean(requestedEntry); + const requestedActiveId = requestedEntry?.id || ""; const playerCampaigns = nextCampaigns.filter(openWorldsPlayerChronicle); - const activeStillExists = playerCampaigns.some((c) => c.id === s?.activeCampaign); + const activeStillExists = playerCampaigns.some((c) => openWorldsCampaignMatches(c, s?.activeCampaign)); const preferred = - (requestedStillExists ? requestedCampaign : "") || + requestedActiveId || playerCampaigns.find((c) => c.current)?.id || playerCampaigns.find((c) => c.live && c.canResume)?.id || playerCampaigns.find((c) => c.canResume)?.id || @@ -727,7 +736,7 @@ function App() { return { ...s, campaigns: nextCampaigns, - activeCampaign: requestedStillExists ? requestedCampaign : (activeStillExists ? s.activeCampaign : preferred), + activeCampaign: requestedActiveId || (activeStillExists ? s.activeCampaign : preferred), campaignCatalog: { loaded: true, total: payload?.total ?? nextCampaigns.length, @@ -906,7 +915,7 @@ function App() { const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const playerChronicles = campaigns.filter(openWorldsPlayerChronicle); const current = - playerChronicles.find((c) => c.id === state?.activeCampaign) || + playerChronicles.find((c) => openWorldsCampaignMatches(c, state?.activeCampaign)) || playerChronicles[0] || { title: "Open Worlds", day: "" }; diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 70d23408..40a5d649 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -530,8 +530,9 @@ def test_launcher_shelf_filters_non_resumable_scratch_runs(self): self.assertIn('style={{ width: 86, textAlign: "center" }}', launcher) self.assertIn('w={86} h={104}', launcher) self.assertIn("function openWorldsPlayerChronicle(c)", app) + self.assertIn("function openWorldsCampaignMatches(c, campaignRef)", app) self.assertIn("const playerCampaigns = nextCampaigns.filter(openWorldsPlayerChronicle);", app) - self.assertIn("const activeStillExists = playerCampaigns.some((c) => c.id === s?.activeCampaign);", app) + self.assertIn("const activeStillExists = playerCampaigns.some((c) => openWorldsCampaignMatches(c, s?.activeCampaign));", app) self.assertIn("playerCampaigns.find((c) => c.current)?.id", app) self.assertIn("playerCampaigns.find((c) => c.live && c.canResume)?.id", app) self.assertIn("playerCampaigns.find((c) => c.canResume)?.id", app) @@ -864,8 +865,11 @@ def test_openworlds_app_honors_campaign_deep_link_once(self): self.assertIn("new URLSearchParams(window.location.search || \"\")", source) self.assertIn('params.get("campaign")', source) self.assertIn("requestedCampaignRef", source) + self.assertIn("openWorldsCampaignMatches(c, requestedCampaign)", source) + self.assertIn("requestedActiveId", source) self.assertIn("requestedStillExists", source) - self.assertLess(source.index("requestedStillExists ? requestedCampaign"), source.index("playerCampaigns.find((c) => c.current)?.id")) + self.assertLess(source.index("requestedActiveId ||"), source.index("playerCampaigns.find((c) => c.current)?.id")) + self.assertIn("c?.id === campaignRef || c?.campaign_id === campaignRef", source) self.assertIn("requestedCampaignRef.current = \"\"", source) def test_openworlds_camp_rest_gives_feedback_when_dm_is_busy(self): From 59d2ca5707e550cff35815beec9aca111b2e49ed Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 04:20:39 +0700 Subject: [PATCH 2/6] Fix app-status busy turn readiness --- qa/app_handoff_gate.py | 15 ++++- qa/test_app_handoff_gate.py | 73 +++++++++++++++++++++++ viewer/server.py | 59 ++++++++++++++++--- viewer/tests/test_openworlds_static.py | 81 ++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 11 deletions(-) diff --git a/qa/app_handoff_gate.py b/qa/app_handoff_gate.py index 61f8da10..c2a58096 100644 --- a/qa/app_handoff_gate.py +++ b/qa/app_handoff_gate.py @@ -533,8 +533,18 @@ def drive_moves( deadline = time.time() + timeout advanced = False + last_status_error = "" while time.time() < deadline: - status = smoke.wait_for_status(base_url, gate_dir, timeout=3) + try: + status = smoke.wait_for_status(base_url, gate_dir, timeout=3) + except Exception as exc: # noqa: BLE001 - keep polling through transient busy/status timeouts. + last_status_error = str(exc) + append_ndjson( + gate_dir / "network.ndjson", + {"at": time.time(), "method": "GET", "url": urllib.parse.urljoin(base_url, "/app-status"), "error": last_status_error}, + ) + time.sleep(1 if provider != "scripted" else 0.5) + continue chat_lines = int(((status.get("viewer") or {}).get("chat_lines") or 0) if isinstance(status.get("viewer"), dict) else 0) if chat_lines > last_chat_lines: if provider != "scripted": @@ -556,7 +566,8 @@ def drive_moves( smoke.write_text_snapshot(gate_dir / "a11y" / f"beat-{beat}.html", smoke.html_text(base_url)) smoke.capture_openworlds_screenshot(base_url=base_url, out=gate_dir, port=expected_port, label=f"beat-{beat:03d}", gaps=gaps, screenshots=screenshots) if not advanced: - return False, "no_narration", f"narration did not advance after {provider} beat {beat}", {"screenshots": screenshots, "evidence_gaps": gaps} + suffix = f"; last app-status error: {last_status_error}" if last_status_error else "" + return False, "no_narration", f"narration did not advance after {provider} beat {beat}{suffix}", {"screenshots": screenshots, "evidence_gaps": gaps} final_status = smoke.wait_for_status(base_url, gate_dir, timeout=5) json_dump(gate_dir / "app-status.final.json", final_status) diff --git a/qa/test_app_handoff_gate.py b/qa/test_app_handoff_gate.py index 770ab896..7b7ceeaa 100644 --- a/qa/test_app_handoff_gate.py +++ b/qa/test_app_handoff_gate.py @@ -243,6 +243,79 @@ def test_hook_probe_summary_reports_exact_missing_controls(self): self.assertIn("settings:provider-status", detail) self.assertEqual(payload["schema"], "worldos.app-handoff-hooks.v1") + def test_drive_moves_tolerates_transient_app_status_timeout(self): + status_initial = { + "schema": "worldos.app-status.v1", + "build": {"sha": "abc1234"}, + "viewer": {"port": 8899, "chat_lines": 1}, + "art": {"private_root_present": True}, + "live": { + "can_act": True, + "actor": {"id": "char_1", "name": "Alfira"}, + "enabled_action_count": 6, + }, + "readiness": {"ready_for_smoke": True, "ready_for_play": True, "failure_bucket": "none"}, + "health": {"failure_bucket": "none"}, + } + status_after = { + **status_initial, + "viewer": {"port": 8899, "chat_lines": 2}, + } + surface = {"recentEvents": [{"kind": "narration", "text": "Opening."}]} + + with tempfile.TemporaryDirectory() as td: + gate_dir = Path(td) + with mock.patch.object( + gate.smoke, + "wait_for_status", + side_effect=[status_initial, TimeoutError("busy status probe"), status_after, status_after], + ), mock.patch.object( + gate.smoke, + "fetch_json", + return_value=(surface, 200), + ), mock.patch.object( + gate.smoke, + "html_text", + return_value="
WorldOS
", + ), mock.patch.object( + gate.smoke, + "capture_openworlds_screenshot", + return_value=None, + ), mock.patch.object( + gate.smoke, + "post_json", + return_value=({"ok": True}, 200), + ), mock.patch.object( + gate.smoke, + "copy_play_state", + return_value=None, + ), mock.patch.object( + gate, + "run_hook_probe", + return_value=(True, "", {"ok": True}), + ), mock.patch.object( + gate, + "provider_trace_summary", + return_value={"trace_exists": True, "failed_or_error_count": 0}, + ), mock.patch.object(gate.time, "sleep", return_value=None): + ok, bucket, detail, details = gate.drive_moves( + base_url="http://127.0.0.1:8899", + gate_dir=gate_dir, + run_id="fixture-run", + provider="codex", + beats=1, + timeout=5, + expected_sha="abc1234", + expected_port=8899, + ) + + network = (gate_dir / "network.ndjson").read_text(encoding="utf-8") + + self.assertTrue(ok, detail) + self.assertEqual(bucket, "") + self.assertIn("busy status probe", network) + self.assertEqual(details["provider_trace"]["trace_exists"], True) + if __name__ == "__main__": unittest.main() diff --git a/viewer/server.py b/viewer/server.py index 1c717db1..6f3eaefa 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -5415,6 +5415,31 @@ def _file_line_count(path: str) -> int: return 0 +def _chat_file_summary(path: str) -> dict: + """Summarize the two-sided chat tail without mutating live campaign state.""" + summary = {"line_count": 0, "last_role": "", "pending_player_turn": False} + if not path: + return summary + last: dict | None = None + try: + for line in Path(path).read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + summary["line_count"] += 1 + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + last = payload + except OSError: + return summary + role = str((last or {}).get("role") or "").strip().lower() + summary["last_role"] = role + summary["pending_player_turn"] = role == "player" + return summary + + def _repo_build_sha() -> str: env = env_var("BUILD_SHA", "") if env and env.strip(): @@ -5502,12 +5527,13 @@ def iter_ndjson(path_value: str | None): def _app_status_readiness(*, live: dict | None, moves: Path | None, is_live_view: bool, actor: dict, enabled_actions: list[str], art_root: Path, chat_lines: int, surface: dict, - provider: str, console_errors: int = 0, + provider: str, pending_player_turn: bool = False, + console_errors: int = 0, network_failures: int = 0) -> tuple[dict, dict]: same_port_alive = True # This payload was generated by the same port that answered /app-status. route_loaded = True moves_writable = bool(live and moves is not None) - can_act = bool(surface.get("can_act")) + surface_can_act = bool(surface.get("can_act")) provider_ready = bool(live and provider.strip()) actor_present = bool(actor.get("id") or actor.get("name")) actions_present = bool(enabled_actions) @@ -5530,7 +5556,7 @@ def _app_status_readiness(*, live: dict | None, moves: Path | None, is_live_view elif not art_present or not image_probe_ok: failure_bucket = "no_art" failure_detail = "private art root or representative image probe is missing" - elif not provider_ready or not moves_writable or not is_live_view or not can_act: + elif not provider_ready or not moves_writable or not is_live_view or not surface_can_act: failure_bucket = "no_provider" failure_detail = "live provider move sink is not ready" elif not actor_present: @@ -5550,8 +5576,12 @@ def _app_status_readiness(*, live: dict | None, moves: Path | None, is_live_view failure_detail = "browser network failures were reported" ready_for_smoke = failure_bucket == "none" - ready_for_play = ready_for_smoke and provider.strip().lower() in {"codex", "claude", "openclaw", "scripted"} - status = "ready" if ready_for_smoke else "degraded" + ready_for_play = ( + ready_for_smoke + and not pending_player_turn + and provider.strip().lower() in {"codex", "claude", "openclaw", "scripted"} + ) + status = "busy" if ready_for_smoke and pending_player_turn else ("ready" if ready_for_smoke else "degraded") health = { "same_port_alive": same_port_alive, "route_loaded": route_loaded, @@ -5559,6 +5589,7 @@ def _app_status_readiness(*, live: dict | None, moves: Path | None, is_live_view "network_failures": network_failures, "provider_ready": provider_ready, "image_probe_ok": image_probe_ok, + "pending_player_turn": bool(pending_player_turn), "failure_bucket": failure_bucket, "failure_detail": failure_detail, } @@ -5566,6 +5597,7 @@ def _app_status_readiness(*, live: dict | None, moves: Path | None, is_live_view "status": status, "ready_for_smoke": ready_for_smoke, "ready_for_play": ready_for_play, + "pending_player_turn": bool(pending_player_turn), "failure_bucket": failure_bucket, "failure_detail": failure_detail, } @@ -5602,7 +5634,9 @@ def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign art_root = _ingested_images_root() state_root = _state_dir() provider = env_var("PROVIDER", "") or "" - chat_lines = _file_line_count(chat_path) + chat_summary = _chat_file_summary(chat_path) + chat_lines = int(chat_summary.get("line_count") or 0) + pending_player_turn = bool(chat_summary.get("pending_player_turn")) console_errors, network_failures = _browser_health_counts( env_var("BROWSER_CONSOLE_LOG", ""), env_var("BROWSER_NETWORK_LOG", ""), @@ -5617,9 +5651,12 @@ def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign chat_lines=chat_lines, surface=surface, provider=provider, + pending_player_turn=pending_player_turn, console_errors=console_errors, network_failures=network_failures, ) + effective_can_act = bool(surface.get("can_act")) and not pending_player_turn + effective_enabled_actions = enabled_actions if effective_can_act else [] return { "ok": True, "schema": "worldos.app-status.v1", @@ -5638,6 +5675,7 @@ def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign "transcript_path": transcript_path, "chat_path": chat_path, "chat_lines": chat_lines, + "last_chat_role": str(chat_summary.get("last_role") or ""), }, "art": { "repo_root": _resolved(_art_repo_root()), @@ -5652,14 +5690,17 @@ def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign "moves_path": _resolved(moves) if moves is not None else "", "moves_writable": bool(live), "is_live_view": is_live_view, - "can_act": bool(surface.get("can_act")), + "surface_can_act": bool(surface.get("can_act")), + "pending_player_turn": pending_player_turn, + "can_act": effective_can_act, "actor": { "id": str(actor.get("id") or ""), "name": str(actor.get("name") or ""), "kind": str(actor.get("kind") or ""), }, - "enabled_action_ids": enabled_actions, - "enabled_action_count": len(enabled_actions), + "surface_enabled_action_ids": enabled_actions, + "enabled_action_ids": effective_enabled_actions, + "enabled_action_count": len(effective_enabled_actions), }, "readiness": readiness, "health": health, diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 40a5d649..10a88c9f 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -32,12 +32,16 @@ def setUp(self): self._old_clawdnd_repo_root = os.environ.get("CLAWDND_REPO_ROOT") self._old_worldos_player_moves = os.environ.get("WORLDOS_PLAYER_MOVES") self._old_clawdnd_player_moves = os.environ.get("CLAWDND_PLAYER_MOVES") + self._old_worldos_provider = os.environ.get("WORLDOS_PROVIDER") + self._old_clawdnd_provider = os.environ.get("CLAWDND_PROVIDER") os.environ.pop("WORLDOS_ART_REPO_ROOT", None) os.environ.pop("CLAWDND_ART_REPO_ROOT", None) os.environ.pop("WORLDOS_REPO_ROOT", None) os.environ.pop("CLAWDND_REPO_ROOT", None) os.environ.pop("WORLDOS_PLAYER_MOVES", None) os.environ.pop("CLAWDND_PLAYER_MOVES", None) + os.environ.pop("WORLDOS_PROVIDER", None) + os.environ.pop("CLAWDND_PROVIDER", None) self._old_here = server._HERE os.environ["CLAWDND_STATE_DIR"] = str(self._tmp) _QuietHandler.campaign_id = "" @@ -81,6 +85,14 @@ def tearDown(self): os.environ.pop("CLAWDND_PLAYER_MOVES", None) else: os.environ["CLAWDND_PLAYER_MOVES"] = self._old_clawdnd_player_moves + if self._old_worldos_provider is None: + os.environ.pop("WORLDOS_PROVIDER", None) + else: + os.environ["WORLDOS_PROVIDER"] = self._old_worldos_provider + if self._old_clawdnd_provider is None: + os.environ.pop("CLAWDND_PROVIDER", None) + else: + os.environ["CLAWDND_PROVIDER"] = self._old_clawdnd_provider server._HERE = self._old_here def _get(self, path: str) -> tuple[int, str, bytes]: @@ -303,6 +315,75 @@ def test_app_status_route_exposes_agent_probe_contract(self): self.assertIn("failure_bucket", payload["health"]) self.assertEqual(payload["endpoints"]["session_surface"], "/session-surface") + def test_app_status_reports_busy_turn_when_last_chat_row_is_player(self): + campaign_dir = self._tmp / "campaigns" / "camp_live" + self._write_snapshot( + campaign_dir, + { + "id": "camp_live", + "title": "Live Probe Save", + "active_session_id": "session_live", + "world_id": "baldurs-gate", + "current_location_id": "loc-lower-city", + "locations": { + "loc-lower-city": { + "id": "loc-lower-city", + "name": "Lower City", + }, + }, + "party": ["hero"], + "characters": { + "hero": { + "id": "hero", + "name": "Probe Hero", + "kind": "player", + "current_hp": 8, + "max_hp": 8, + }, + }, + }, + ) + moves = self._tmp / "play-123" / "player_moves.jsonl" + moves.parent.mkdir() + moves.write_text(json.dumps({"kind": "do", "text": "Act."}) + "\n", encoding="utf-8") + art_root = self._tmp / "art-root" + image_dir = art_root / "content" / "worlds" / "_private" / "baldurs-gate" / "images" / "location_loc-lower-city" + image_dir.mkdir(parents=True) + (image_dir / "wiki_ingest.json").write_text( + json.dumps({"scope": "location:loc-lower-city", "url": "https://example.invalid/lower-city.png"}), + encoding="utf-8", + ) + chat = self._tmp / "play-123" / "chat.jsonl" + chat.write_text( + json.dumps({"role": "dm", "text": "Opening."}) + "\n" + + json.dumps({"role": "player", "text": "[do] Act."}) + "\n", + encoding="utf-8", + ) + os.environ["CLAWDND_PLAYER_MOVES"] = str(moves) + os.environ["WORLDOS_ART_REPO_ROOT"] = str(art_root) + os.environ["WORLDOS_PROVIDER"] = "codex" + _QuietHandler.campaign_id = "camp_live" + _QuietHandler.chat_path = str(chat) + + status, _ctype, body = self._get("/app-status?campaign=camp_live") + + self.assertEqual(status, 200) + payload = json.loads(body.decode("utf-8")) + self.assertEqual(payload["viewer"]["chat_lines"], 2) + self.assertEqual(payload["viewer"]["last_chat_role"], "player") + self.assertTrue(payload["live"]["surface_can_act"]) + self.assertTrue(payload["live"]["pending_player_turn"]) + self.assertFalse(payload["live"]["can_act"]) + self.assertIn("continue", payload["live"]["surface_enabled_action_ids"]) + self.assertEqual(payload["live"]["enabled_action_ids"], []) + self.assertEqual(payload["live"]["enabled_action_count"], 0) + self.assertEqual(payload["readiness"]["status"], "busy") + self.assertTrue(payload["readiness"]["ready_for_smoke"]) + self.assertFalse(payload["readiness"]["ready_for_play"]) + self.assertTrue(payload["readiness"]["pending_player_turn"]) + self.assertTrue(payload["health"]["pending_player_turn"]) + self.assertEqual(payload["health"]["failure_bucket"], "none") + def test_app_status_browser_health_counts_console_and_network_logs(self): console = self._tmp / "console.ndjson" network = self._tmp / "network.ndjson" From e53831da4ca29226a54e96c2401617e0a0c927c7 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 04:43:48 +0700 Subject: [PATCH 3/6] Fix handoff gate DM-reply evidence --- qa/app_handoff_gate.py | 49 +++++++++++++++++++------- qa/test_app_handoff_gate.py | 41 +++++++++++++++++++-- viewer/server.py | 2 +- viewer/tests/test_openworlds_static.py | 14 ++++++++ 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/qa/app_handoff_gate.py b/qa/app_handoff_gate.py index c2a58096..7c632a93 100644 --- a/qa/app_handoff_gate.py +++ b/qa/app_handoff_gate.py @@ -270,6 +270,21 @@ def evidence_gap_count(payload: dict[str, Any]) -> int: return len(gaps) if isinstance(gaps, list) else 0 +def evidence_manifest_blockers(payload: dict[str, Any]) -> list[str]: + blockers: list[str] = [] + gap_count = evidence_gap_count(payload) + if gap_count: + blockers.append(f"evidence gaps: {gap_count}") + handoff_gate = payload.get("handoff_gate") if isinstance(payload.get("handoff_gate"), dict) else {} + if handoff_gate.get("ok") is False: + reasons = handoff_gate.get("blocking_reasons") + if isinstance(reasons, list) and reasons: + blockers.extend(str(reason) for reason in reasons) + else: + blockers.append("handoff evidence marked ok:false") + return blockers + + @dataclass class GateResult: name: str @@ -548,14 +563,22 @@ def drive_moves( chat_lines = int(((status.get("viewer") or {}).get("chat_lines") or 0) if isinstance(status.get("viewer"), dict) else 0) if chat_lines > last_chat_lines: if provider != "scripted": - advanced = True - last_chat_lines = chat_lines - break - summary = smoke.provider_summary(ROOT / "play-state" / run_id) - if int(summary.get("resolved_move_count") or summary.get("move_resolved_count") or 0) >= beat: - advanced = True - last_chat_lines = chat_lines - break + viewer = status.get("viewer") if isinstance(status.get("viewer"), dict) else {} + readiness = status.get("readiness") if isinstance(status.get("readiness"), dict) else {} + last_role = str(viewer.get("last_chat_role") or "").strip().lower() + ready_for_play = readiness.get("ready_for_play") is True + # Non-scripted providers echo the player's move before the DM reply. + # Count only the post-move DM tail plus a playable app status as narration advance. + if chat_lines >= last_chat_lines + 2 and last_role == "dm" and ready_for_play: + advanced = True + last_chat_lines = chat_lines + break + else: + summary = smoke.provider_summary(ROOT / "play-state" / run_id) + if int(summary.get("resolved_move_count") or summary.get("move_resolved_count") or 0) >= beat: + advanced = True + last_chat_lines = chat_lines + break time.sleep(1 if provider != "scripted" else 0.5) json_dump(gate_dir / f"app-status.beat-{beat}.json", status) try: @@ -634,8 +657,9 @@ def run_web_scripted(args: argparse.Namespace, out: Path, expected_sha: str) -> verdict=gate.status, ) gate.evidence_manifest = manifest_path - if gate.status == "passed" and evidence_gap_count(manifest): - gate.fail("no_provider", "web scripted evidence manifest has gaps") + blockers = evidence_manifest_blockers(manifest) if gate.status == "passed" else [] + if blockers: + gate.fail("no_provider", "web scripted evidence manifest is not handoff-ready: " + "; ".join(blockers)) gate.evidence_gaps = manifest.get("evidence_gaps", []) elif gate.status != "passed": gate.evidence_gaps = manifest.get("evidence_gaps", []) if isinstance(manifest.get("evidence_gaps"), list) else gate.evidence_gaps @@ -764,8 +788,9 @@ def run_native_provider_gate( gate.evidence_manifest = export_path gate.evidence_gaps = manifest.get("evidence_gaps", []) if isinstance(manifest.get("evidence_gaps"), list) else [] cleanup_run(minted_run, gate.port) - if gate.status == "passed" and evidence_gap_count(read_json(Path(gate.evidence_manifest))): - gate.fail("no_provider", "native evidence manifest has gaps") + manifest_blockers = evidence_manifest_blockers(read_json(Path(gate.evidence_manifest))) if gate.status == "passed" else [] + if manifest_blockers: + gate.fail("no_provider", "native evidence manifest is not handoff-ready: " + "; ".join(manifest_blockers)) return gate diff --git a/qa/test_app_handoff_gate.py b/qa/test_app_handoff_gate.py index 7b7ceeaa..d8f919c9 100644 --- a/qa/test_app_handoff_gate.py +++ b/qa/test_app_handoff_gate.py @@ -221,6 +221,20 @@ def test_export_evidence_persists_failure_manifest(self): self.assertIn("export_app_evidence exited 17", payload["failure"]["failure_detail"]) self.assertEqual(persisted, payload) + def test_evidence_manifest_blockers_include_handoff_gate_reasons(self): + payload = { + "evidence_gaps": [], + "handoff_gate": { + "ok": False, + "blocking_reasons": ["can_act not true", "no enabled actions"], + }, + } + + self.assertEqual( + gate.evidence_manifest_blockers(payload), + ["can_act not true", "no enabled actions"], + ) + def test_hook_probe_summary_reports_exact_missing_controls(self): with tempfile.TemporaryDirectory() as td: path = Path(td) / "hook-probe.json" @@ -247,7 +261,7 @@ def test_drive_moves_tolerates_transient_app_status_timeout(self): status_initial = { "schema": "worldos.app-status.v1", "build": {"sha": "abc1234"}, - "viewer": {"port": 8899, "chat_lines": 1}, + "viewer": {"port": 8899, "chat_lines": 1, "last_chat_role": "dm"}, "art": {"private_root_present": True}, "live": { "can_act": True, @@ -257,9 +271,27 @@ def test_drive_moves_tolerates_transient_app_status_timeout(self): "readiness": {"ready_for_smoke": True, "ready_for_play": True, "failure_bucket": "none"}, "health": {"failure_bucket": "none"}, } + status_busy = { + **status_initial, + "viewer": {"port": 8899, "chat_lines": 2, "last_chat_role": "player"}, + "live": { + "can_act": False, + "actor": {"id": "char_1", "name": "Alfira"}, + "enabled_action_count": 0, + "pending_player_turn": True, + }, + "readiness": { + "status": "busy", + "ready_for_smoke": True, + "ready_for_play": False, + "pending_player_turn": True, + "failure_bucket": "none", + }, + "health": {"failure_bucket": "none", "pending_player_turn": True}, + } status_after = { **status_initial, - "viewer": {"port": 8899, "chat_lines": 2}, + "viewer": {"port": 8899, "chat_lines": 3, "last_chat_role": "dm"}, } surface = {"recentEvents": [{"kind": "narration", "text": "Opening."}]} @@ -268,7 +300,7 @@ def test_drive_moves_tolerates_transient_app_status_timeout(self): with mock.patch.object( gate.smoke, "wait_for_status", - side_effect=[status_initial, TimeoutError("busy status probe"), status_after, status_after], + side_effect=[status_initial, TimeoutError("busy status probe"), status_busy, status_after, status_after], ), mock.patch.object( gate.smoke, "fetch_json", @@ -310,10 +342,13 @@ def test_drive_moves_tolerates_transient_app_status_timeout(self): ) network = (gate_dir / "network.ndjson").read_text(encoding="utf-8") + beat_status = json.loads((gate_dir / "app-status.beat-1.json").read_text(encoding="utf-8")) self.assertTrue(ok, detail) self.assertEqual(bucket, "") + self.assertEqual(detail, "") self.assertIn("busy status probe", network) + self.assertEqual(beat_status["viewer"]["last_chat_role"], "dm") self.assertEqual(details["provider_trace"]["trace_exists"], True) diff --git a/viewer/server.py b/viewer/server.py index 6f3eaefa..a7e003d9 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -5425,12 +5425,12 @@ def _chat_file_summary(path: str) -> dict: for line in Path(path).read_text(encoding="utf-8").splitlines(): if not line.strip(): continue - summary["line_count"] += 1 try: payload = json.loads(line) except json.JSONDecodeError: continue if isinstance(payload, dict): + summary["line_count"] += 1 last = payload except OSError: return summary diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 10a88c9f..aeda17da 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -384,6 +384,20 @@ def test_app_status_reports_busy_turn_when_last_chat_row_is_player(self): self.assertTrue(payload["health"]["pending_player_turn"]) self.assertEqual(payload["health"]["failure_bucket"], "none") + def test_chat_file_summary_ignores_malformed_trailing_row(self): + chat = self._tmp / "chat.jsonl" + chat.write_text( + json.dumps({"role": "dm", "text": "Opening."}) + "\n" + + '{"role":"player","text":"half-written"', + encoding="utf-8", + ) + + summary = server._chat_file_summary(str(chat)) + + self.assertEqual(summary["line_count"], 1) + self.assertEqual(summary["last_role"], "dm") + self.assertFalse(summary["pending_player_turn"]) + def test_app_status_browser_health_counts_console_and_network_logs(self): console = self._tmp / "console.ndjson" network = self._tmp / "network.ndjson" From 47d932a72bdbdadc82851007a1971d3a2d2118b1 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 04:52:46 +0700 Subject: [PATCH 4/6] Fail handoff on smoke evidence gaps --- qa/app_handoff_gate.py | 6 +++++- qa/test_app_handoff_gate.py | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/qa/app_handoff_gate.py b/qa/app_handoff_gate.py index 7c632a93..2ab54575 100644 --- a/qa/app_handoff_gate.py +++ b/qa/app_handoff_gate.py @@ -658,9 +658,13 @@ def run_web_scripted(args: argparse.Namespace, out: Path, expected_sha: str) -> ) gate.evidence_manifest = manifest_path blockers = evidence_manifest_blockers(manifest) if gate.status == "passed" else [] + if gate.status == "passed" and gate.evidence_gaps: + blockers.insert(0, f"smoke evidence gaps: {len(gate.evidence_gaps)}") if blockers: gate.fail("no_provider", "web scripted evidence manifest is not handoff-ready: " + "; ".join(blockers)) - gate.evidence_gaps = manifest.get("evidence_gaps", []) + manifest_gaps = manifest.get("evidence_gaps", []) + if isinstance(manifest_gaps, list): + gate.evidence_gaps.extend(manifest_gaps) elif gate.status != "passed": gate.evidence_gaps = manifest.get("evidence_gaps", []) if isinstance(manifest.get("evidence_gaps"), list) else gate.evidence_gaps return gate diff --git a/qa/test_app_handoff_gate.py b/qa/test_app_handoff_gate.py index d8f919c9..9fbda216 100644 --- a/qa/test_app_handoff_gate.py +++ b/qa/test_app_handoff_gate.py @@ -5,6 +5,7 @@ import tempfile import unittest from pathlib import Path +from types import SimpleNamespace from unittest import mock from qa import app_handoff_gate as gate @@ -235,6 +236,44 @@ def test_evidence_manifest_blockers_include_handoff_gate_reasons(self): ["can_act not true", "no enabled actions"], ) + def test_run_web_scripted_fails_on_smoke_evidence_gaps(self): + with tempfile.TemporaryDirectory() as td: + out = Path(td) + args = SimpleNamespace(run_id="fixture", web_beats=1, web_port=8899, timeout=1.0, art_root=None) + smoke_payload = { + "status": "passed", + "evidence_gaps": [{"source": "screenshot", "kind": "initial", "reason": "chrome_exit=None"}], + } + final_status = {"schema": "worldos.app-status.v1"} + + def fake_read_json(path): + path = Path(path) + if path.name == "smoke.json": + return smoke_payload + if path.name == "app-status.final.json": + return final_status + return {} + + with mock.patch.object(gate, "run_logged", return_value=0), mock.patch.object( + gate, + "read_json", + side_effect=fake_read_json, + ), mock.patch.object( + gate, + "validate_app_status", + return_value=("", ""), + ), mock.patch.object( + gate, + "export_evidence", + return_value=(out / "manifest.json", {"evidence_gaps": []}), + ): + result = gate.run_web_scripted(args, out, "abc1234") + + self.assertEqual(result.status, "failed") + self.assertEqual(result.failure_bucket, "no_provider") + self.assertIn("smoke evidence gaps: 1", result.failure_detail) + self.assertEqual(result.evidence_gaps, smoke_payload["evidence_gaps"]) + def test_hook_probe_summary_reports_exact_missing_controls(self): with tempfile.TemporaryDirectory() as td: path = Path(td) / "hook-probe.json" From 6e5432593fa1b0eb6ebcedc1d11af7e995036d79 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 05:01:13 +0700 Subject: [PATCH 5/6] Avoid live dialogue tool calls in Codex DM --- qa/test_macos_app_static.py | 4 ++++ scripts/play_codex_dm.sh | 4 ++++ servers/engine/tests/test_codex_provider_wrapper.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/qa/test_macos_app_static.py b/qa/test_macos_app_static.py index f53a820e..ae8f11a8 100644 --- a/qa/test_macos_app_static.py +++ b/qa/test_macos_app_static.py @@ -106,8 +106,11 @@ def test_codex_dm_provider_feeds_live_progress_events(self): app = self.read("viewer/openworlds/app.jsx") self.assertIn("LIVE_PROGRESS_LOG_RULE", script) + self.assertIn("LIVE_DIALOGUE_LOG_RULE", script) self.assertIn("visible story progress while your turn is still running", script) self.assertIn("log_event(kind=\\\"narration\\\", text=\\\"...\\\")", script) + self.assertIn("do not call log_event(kind=\\\"dialogue\\\")", script) + self.assertIn("without hiding dialogue from the player", script) self.assertIn("the wrapper records the final reply through the engine after the turn", script) self.assertIn("OPENING_PROGRESS_TEXT=", script) self.assertIn("MOVE_PROGRESS_TEXTS=(", script) @@ -129,6 +132,7 @@ def test_codex_dm_provider_feeds_live_progress_events(self): script.index('codex_dm_turn "You are the Dungeon Master mid-session.'), ) self.assertGreaterEqual(script.count("$LIVE_PROGRESS_LOG_RULE"), 3) + self.assertGreaterEqual(script.count("$LIVE_DIALOGUE_LOG_RULE"), 3) self.assertNotIn( "Do not call log_event for player-facing narration or dialogue in this provider wrapper", script, diff --git a/scripts/play_codex_dm.sh b/scripts/play_codex_dm.sh index 2670e8ef..50d64f15 100755 --- a/scripts/play_codex_dm.sh +++ b/scripts/play_codex_dm.sh @@ -431,6 +431,7 @@ codex_dm_turn() { LOG_EVENT_TOOL_RULE="Tool argument rule: for log_event narration, omit the speaker argument entirely. For dialogue, pass a real non-empty character id or name. Never pass JSON null for speaker or any optional string field." LIVE_PROGRESS_LOG_RULE="Live progress rule: after you know the live campaign and scene, call log_event(kind=\"narration\", text=\"...\") once with a short, non-duplicate, player-facing progress beat before any longer resolution work. This is how /events shows visible story progress while your turn is still running. Keep the final reply as the full 2nd-person scene; do not copy this progress beat verbatim, because the wrapper records the final reply through the engine after the turn." +LIVE_DIALOGUE_LOG_RULE="Live dialogue rule: in this Codex app-provider wrapper, do not call log_event(kind=\"dialogue\"). Put quoted NPC speech inside a narration progress beat or your final reply instead; the wrapper records the final reply after the turn, and narration-only live events avoid provider safety cancellation without hiding dialogue from the player." OPENING_LOG_EVENT_RULE="Opening progress rule: during the opening, after get_state establishes the already-seated player and live scene, write one short sensory progress beat through log_event(kind=\"narration\", text=\"...\") before deeper setup or rules work. Do not log the full opening this way; your final reply must still be non-empty opening narration for the player." STATE_DISCOVERY_RULE="State discovery rule: after reading skills/dungeon-master/SKILL.md, use clawdnd-engine/clawdnd-rules MCP tools for live game state. Do not use shell commands, rg, find, or filesystem reads to discover campaign state." STARTUP_MUTATION_RULE="Startup mutation rule: the wrapper has already seated the one player before you are called. Before the first player-facing narration, do not call start_world, start_session, start_character, load_canon_character, create_character, or recruit_companion. Introduce scene NPCs in narration first; create or load a tracked NPC only after the player engages them." @@ -500,6 +501,7 @@ Before acting, read skills/dungeon-master/SKILL.md and follow its live-world con $LOG_EVENT_TOOL_RULE $LIVE_PROGRESS_LOG_RULE +$LIVE_DIALOGUE_LOG_RULE $OPENING_LOG_EVENT_RULE $STATE_DISCOVERY_RULE $STARTUP_MUTATION_RULE @@ -530,6 +532,7 @@ Before acting, read skills/dungeon-master/SKILL.md and follow its live-world con $LOG_EVENT_TOOL_RULE $LIVE_PROGRESS_LOG_RULE +$LIVE_DIALOGUE_LOG_RULE $OPENING_LOG_EVENT_RULE $STATE_DISCOVERY_RULE $STARTUP_MUTATION_RULE @@ -579,6 +582,7 @@ while true; do $LOG_EVENT_TOOL_RULE $LIVE_PROGRESS_LOG_RULE +$LIVE_DIALOGUE_LOG_RULE $STATE_DISCOVERY_RULE $CAMPAIGN_TOOL_HINT $SOCIAL_CHECK_TARGET_RULE diff --git a/servers/engine/tests/test_codex_provider_wrapper.py b/servers/engine/tests/test_codex_provider_wrapper.py index 9a07a1d2..4af88a2f 100644 --- a/servers/engine/tests/test_codex_provider_wrapper.py +++ b/servers/engine/tests/test_codex_provider_wrapper.py @@ -186,16 +186,20 @@ def test_codex_dm_wrapper_forbids_null_speaker_arguments(): assert "LOG_EVENT_TOOL_RULE=" in source assert "LIVE_PROGRESS_LOG_RULE=" in source + assert "LIVE_DIALOGUE_LOG_RULE=" in source assert "OPENING_LOG_EVENT_RULE=" in source assert "omit the speaker argument entirely" in source assert "Never pass JSON null for speaker" in source assert "visible story progress while your turn is still running" in source + assert 'do not call log_event(kind=\\"dialogue\\")' in source + assert "without hiding dialogue from the player" in source assert "Do not log the full opening this way" in source assert "log_engine_narration" in source assert '[ -n "${campaign_id//[[:space:]]/}" ] || return 1' in source assert '[ -n "${text//[[:space:]]/}" ] || return 1' in source assert source.count("$LOG_EVENT_TOOL_RULE") >= 3 assert source.count("$LIVE_PROGRESS_LOG_RULE") == 3 + assert source.count("$LIVE_DIALOGUE_LOG_RULE") == 3 assert 'record_dm_reply "$ACTIVE_CAMPAIGN_ID" "$REPLY" "move"' in source assert '"engine_logged":true' in source assert "invalid chatlog extra_json" in source From 08c3ad544cf668c7ca0a3cba337a1d0c1ad6b35f Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 05:08:08 +0700 Subject: [PATCH 6/6] Stabilize handoff screenshot evidence capture --- qa/app_handoff_gate.py | 6 ++- qa/app_smoke_scripted.py | 95 +++++++++++++++++++------------------ qa/test_app_handoff_gate.py | 62 ++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 47 deletions(-) diff --git a/qa/app_handoff_gate.py b/qa/app_handoff_gate.py index 2ab54575..e738450a 100644 --- a/qa/app_handoff_gate.py +++ b/qa/app_handoff_gate.py @@ -772,6 +772,9 @@ def run_native_provider_gate( expected_port=gate.port, ) gate.details.update(details) + detail_gaps = details.get("evidence_gaps") if isinstance(details.get("evidence_gaps"), list) else [] + if detail_gaps: + gate.evidence_gaps.extend(detail_gaps) if not ok: gate.fail(bucket, detail) else: @@ -790,7 +793,8 @@ def run_native_provider_gate( verdict=gate.status, ) gate.evidence_manifest = export_path - gate.evidence_gaps = manifest.get("evidence_gaps", []) if isinstance(manifest.get("evidence_gaps"), list) else [] + manifest_gaps = manifest.get("evidence_gaps", []) if isinstance(manifest.get("evidence_gaps"), list) else [] + gate.evidence_gaps.extend(manifest_gaps) cleanup_run(minted_run, gate.port) manifest_blockers = evidence_manifest_blockers(read_json(Path(gate.evidence_manifest))) if gate.status == "passed" else [] if manifest_blockers: diff --git a/qa/app_smoke_scripted.py b/qa/app_smoke_scripted.py index 9919e144..36addc6e 100644 --- a/qa/app_smoke_scripted.py +++ b/qa/app_smoke_scripted.py @@ -193,58 +193,61 @@ def capture_openworlds_screenshot( if not chrome: gaps.append({"source": "screenshot", "kind": label, "path": str(target), "reason": "chrome_not_found"}) return - profile = out / ".chrome-profile" / f"{port}-{label}" - profile.mkdir(parents=True, exist_ok=True) url = f"{base_url}/openworlds/#table" - cmd = [ - chrome, - "--headless=new", - "--disable-gpu", - "--hide-scrollbars", - "--force-device-scale-factor=1", - "--window-size=1512,982", - f"--user-data-dir={profile}", - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - "--disable-default-apps", - "--disable-sync", - "--virtual-time-budget=5000", - f"--screenshot={target}", - url, - ] - proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True) - deadline = time.time() + 12 - while time.time() < deadline: + reasons: list[str] = [] + for attempt in range(1, 3): + profile = out / ".chrome-profile" / f"{port}-{label}-{attempt}" + profile.mkdir(parents=True, exist_ok=True) + cmd = [ + chrome, + "--headless=new", + "--disable-gpu", + "--hide-scrollbars", + "--force-device-scale-factor=1", + "--window-size=1512,982", + f"--user-data-dir={profile}", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-default-apps", + "--disable-sync", + "--virtual-time-budget=7000", + f"--screenshot={target}", + url, + ] + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True) + deadline = time.time() + 20 + while time.time() < deadline: + if target.exists() and target.stat().st_size > 200: + screenshots.append(str(target.relative_to(out))) + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + shutil.rmtree(profile, ignore_errors=True) + return + if proc.poll() is not None: + break + time.sleep(0.25) if target.exists() and target.stat().st_size > 200: screenshots.append(str(target.relative_to(out))) + shutil.rmtree(profile, ignore_errors=True) + return + try: proc.terminate() - try: - proc.wait(timeout=2) - except subprocess.TimeoutExpired: - proc.kill() + proc.wait(timeout=2) + except (ProcessLookupError, subprocess.TimeoutExpired): + proc.kill() + finally: shutil.rmtree(profile, ignore_errors=True) + if target.exists() and target.stat().st_size > 200: + screenshots.append(str(target.relative_to(out))) return - if proc.poll() is not None: - break - time.sleep(0.25) - if target.exists() and target.stat().st_size > 200: - screenshots.append(str(target.relative_to(out))) - shutil.rmtree(profile, ignore_errors=True) - return - try: - proc.terminate() - proc.wait(timeout=2) - except (ProcessLookupError, subprocess.TimeoutExpired): - proc.kill() - finally: - shutil.rmtree(profile, ignore_errors=True) - if target.exists() and target.stat().st_size > 200: - screenshots.append(str(target.relative_to(out))) - return - reason = f"chrome_exit={proc.returncode}" - gaps.append({"source": "screenshot", "kind": label, "path": str(target), "reason": reason}) + reasons.append(f"attempt{attempt}:chrome_exit={proc.returncode}") + time.sleep(0.5) + gaps.append({"source": "screenshot", "kind": label, "path": str(target), "reason": "; ".join(reasons)}) def classify_status(status: dict[str, Any]) -> tuple[str, str]: diff --git a/qa/test_app_handoff_gate.py b/qa/test_app_handoff_gate.py index 9fbda216..f6cc311a 100644 --- a/qa/test_app_handoff_gate.py +++ b/qa/test_app_handoff_gate.py @@ -274,6 +274,68 @@ def fake_read_json(path): self.assertIn("smoke evidence gaps: 1", result.failure_detail) self.assertEqual(result.evidence_gaps, smoke_payload["evidence_gaps"]) + def test_native_provider_gate_preserves_drive_move_evidence_gaps(self): + with tempfile.TemporaryDirectory() as td: + out = Path(td) + args = SimpleNamespace( + run_id="fixture", + world="baldurs-gate", + art_root=None, + timeout=1.0, + codex_timeout=1.0, + ) + gap = {"source": "screenshot", "kind": "initial", "reason": "chrome_exit=None"} + + def fake_read_json(path): + path = Path(path) + if path.name == "run.json": + return { + "part_a": { + "result": "PASS", + "kept_backend_alive": True, + "first_turn_ready": True, + "minted_port": 8767, + "minted_run_dir": "play-fixture", + } + } + if path.name == "transition.json": + return {} + return {} + + with mock.patch.object(gate, "run_logged", return_value=0), mock.patch.object( + gate, + "copy_native_run", + return_value=None, + ), mock.patch.object( + gate, + "read_json", + side_effect=fake_read_json, + ), mock.patch.object( + gate, + "drive_moves", + return_value=(False, "no_provider", "required evidence capture has gaps", {"evidence_gaps": [gap]}), + ), mock.patch.object( + gate, + "export_evidence", + return_value=(out / "manifest.json", {"evidence_gaps": []}), + ), mock.patch.object( + gate, + "cleanup_run", + return_value=None, + ): + result = gate.run_native_provider_gate( + args, + out, + provider="codex", + beats=1, + budget="3.00", + expected_sha="abc1234", + ) + + self.assertEqual(result.status, "failed") + self.assertEqual(result.failure_detail, "required evidence capture has gaps") + self.assertEqual(result.evidence_gaps, [gap]) + def test_hook_probe_summary_reports_exact_missing_controls(self): with tempfile.TemporaryDirectory() as td: path = Path(td) / "hook-probe.json"