Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions qa/app_handoff_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,29 @@ def build_matches(reported: str, expected: str) -> bool:
return reported == expected or expected.startswith(reported) or reported.startswith(expected)


def session_surface_has_narration(surface: dict[str, Any] | None) -> bool:
if not isinstance(surface, dict):
return False
events = surface.get("recentEvents")
if not isinstance(events, list):
return False
for event in events:
if not isinstance(event, dict):
continue
kind = str(event.get("kind") or "").strip().lower()
text = str(event.get("text") or "").strip()
if kind in {"narration", "dialogue"} and text:
return True
return False


def validate_app_status(
status: dict[str, Any],
*,
expected_port: int | None,
expected_sha: str,
require_ready_for_play: bool = True,
session_surface: dict[str, Any] | None = None,
) -> tuple[str, str]:
if not status:
return failure("no_launcher", "app-status JSON is missing")
Expand Down Expand Up @@ -145,8 +162,8 @@ def validate_app_status(
return failure("no_actor", "app-status did not report an active player actor")
if int(live.get("enabled_action_count") or 0) <= 0:
return failure("no_actions", "app-status reported no enabled player actions")
if int(viewer.get("chat_lines") or 0) <= 0:
return failure("no_narration", "app-status reported no chat/narration")
if int(viewer.get("chat_lines") or 0) <= 0 and not session_surface_has_narration(session_surface):
return failure("no_narration", "app-status/session-surface reported no chat/narration")
return "", ""


Expand Down Expand Up @@ -484,18 +501,17 @@ def drive_moves(
json_dump(gate_dir / "app-status.initial.json", status)
smoke.write_text_snapshot(gate_dir / "a11y" / "initial.html", smoke.html_text(base_url))
smoke.capture_openworlds_screenshot(base_url=base_url, out=gate_dir, port=expected_port, label="initial", gaps=gaps, screenshots=screenshots)
bucket, detail = validate_app_status(status, expected_port=expected_port, expected_sha=expected_sha)
if bucket:
return False, bucket, detail, {"screenshots": screenshots, "evidence_gaps": gaps}
hook_ok, hook_detail, hook_payload = run_hook_probe(base_url, gate_dir)
if not hook_ok:
return False, "no_actions", hook_detail, {"screenshots": screenshots, "evidence_gaps": gaps, "hook_probe": hook_payload}

try:
surface, _ = smoke.fetch_json(smoke.surface_url(base_url, status))
json_dump(gate_dir / "session-surface.initial.json", surface)
except Exception as exc: # noqa: BLE001
return False, "no_provider", f"initial session-surface fetch failed: {exc}", {"screenshots": screenshots, "evidence_gaps": gaps}
bucket, detail = validate_app_status(status, expected_port=expected_port, expected_sha=expected_sha, session_surface=surface)
if bucket:
return False, bucket, detail, {"screenshots": screenshots, "evidence_gaps": gaps}
hook_ok, hook_detail, hook_payload = run_hook_probe(base_url, gate_dir)
if not hook_ok:
return False, "no_actions", hook_detail, {"screenshots": screenshots, "evidence_gaps": gaps, "hook_probe": hook_payload}

last_chat_lines = int(((status.get("viewer") or {}).get("chat_lines") or 0) if isinstance(status.get("viewer"), dict) else 0)
move_url = urllib.parse.urljoin(base_url, "/move")
Expand Down
56 changes: 56 additions & 0 deletions qa/test_app_handoff_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,62 @@ def test_app_status_wrong_port_is_no_launcher(self):
self.assertEqual(bucket, "no_launcher")
self.assertIn("expected same port 8899", detail)

def test_app_status_accepts_live_recent_event_narration_when_chat_has_not_landed(self):
status = {
"schema": "worldos.app-status.v1",
"build": {"sha": "abc1234"},
"viewer": {"port": 8899, "chat_lines": 0},
"art": {"private_root_present": True},
"live": {
"can_act": True,
"actor": {"id": "char_1", "name": "Alfira"},
"enabled_action_count": 5,
},
"readiness": {"ready_for_smoke": True, "ready_for_play": True, "failure_bucket": "none"},
"health": {"failure_bucket": "none"},
}
surface = {
"recentEvents": [
{"kind": "system", "text": "Session began"},
{"kind": "narration", "text": "The Lower City resolves around you."},
]
}

bucket, detail = gate.validate_app_status(
status,
expected_port=8899,
expected_sha="abc1234",
session_surface=surface,
)

self.assertEqual(bucket, "")
self.assertEqual(detail, "")

def test_app_status_still_requires_chat_or_live_event_narration(self):
status = {
"schema": "worldos.app-status.v1",
"build": {"sha": "abc1234"},
"viewer": {"port": 8899, "chat_lines": 0},
"art": {"private_root_present": True},
"live": {
"can_act": True,
"actor": {"id": "char_1", "name": "Alfira"},
"enabled_action_count": 5,
},
"readiness": {"ready_for_smoke": True, "ready_for_play": True, "failure_bucket": "none"},
"health": {"failure_bucket": "none"},
}

bucket, detail = gate.validate_app_status(
status,
expected_port=8899,
expected_sha="abc1234",
session_surface={"recentEvents": [{"kind": "system", "text": "Session began"}]},
)

self.assertEqual(bucket, "no_narration")
self.assertIn("session-surface", detail)

def test_codex_provider_trace_cancellations_fail_summary(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td)
Expand Down
40 changes: 40 additions & 0 deletions qa/test_macos_app_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,46 @@ 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_dm_provider_feeds_live_progress_events(self):
script = self.read("scripts/play_codex_dm.sh")
app = self.read("viewer/openworlds/app.jsx")

self.assertIn("LIVE_PROGRESS_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("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)
self.assertIn("choose_move_progress_text() {", script)
self.assertNotIn("The Lower City resolves around you", script)
self.assertIn('log_engine_narration "$HERO_CAMP" "$OPENING_PROGRESS_TEXT"', script)
self.assertIn('MOVE_PROGRESS_TEXT="$(choose_move_progress_text "$DM_TURNS")"', script)
self.assertIn('log_engine_narration "$ACTIVE_CAMPAIGN_ID" "$MOVE_PROGRESS_TEXT"', script)
self.assertLess(
script.index('log_engine_narration "$HERO_CAMP" "$OPENING_PROGRESS_TEXT"'),
script.index('if [ -n "$HERO_CAMP" ]'),
)
self.assertLess(
script.index('MOVE_PROGRESS_TEXT="$(choose_move_progress_text "$DM_TURNS")"'),
script.index('log_engine_narration "$ACTIVE_CAMPAIGN_ID" "$MOVE_PROGRESS_TEXT"'),
)
self.assertLess(
script.index('log_engine_narration "$ACTIVE_CAMPAIGN_ID" "$MOVE_PROGRESS_TEXT"'),
script.index('codex_dm_turn "You are the Dungeon Master mid-session.'),
)
self.assertGreaterEqual(script.count("$LIVE_PROGRESS_LOG_RULE"), 3)
self.assertNotIn(
"Do not call log_event for player-facing narration or dialogue in this provider wrapper",
script,
)
self.assertNotIn(
"Only call log_event during the opening if you need one short non-duplicate system/roll row",
script,
)
self.assertIn("Codex DM wrapper now writes an immediate wrapper-authored engine progress row", app)
self.assertIn("provider to write one short engine-owned progress narration", app)
self.assertIn("when /events progress arrives, it keeps a healthy turn alive", app)

def test_scripted_provider_is_dev_gated_and_model_free(self):
models = self.read("macos/WorldOSApp/Sources/WorldOSApp/Models/ProviderModels.swift")
providers = self.read("macos/WorldOSApp/Sources/WorldOSApp/Services/ProviderAdapters.swift")
Expand Down
32 changes: 27 additions & 5 deletions scripts/play_codex_dm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -423,8 +423,8 @@ 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."
WRAPPER_NARRATION_LOG_RULE="Wrapper narration log rule: Do not call log_event for player-facing narration or dialogue in this provider wrapper. Put visible prose and dialogue in your final reply; the wrapper writes that reply to chat and records it through the engine after the turn. Use log_event only if a later explicit instruction requires a short non-duplicate system/roll row."
OPENING_LOG_EVENT_RULE="Opening log rule: do not call log_event for the full opening narration. Put the player-facing opening prose in your final reply; the wrapper will record that final reply through the engine after the turn. Only call log_event during the opening if you need one short non-duplicate system/roll row."
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."
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."
SOCIAL_CHECK_TARGET_RULE="Social check target rule: call social_check only when scene_context already shows a real tracked npc_id for the target. Do not call load_canon_character or create_character solely to manufacture a social-check target during the same turn. If the target is not already tracked, do not use persuasion, deception, intimidation, or another attitude-moving social skill. Use a non-attitude skill_check such as investigation or perception for what the player can infer, then narrate the scene-local response; persist a new NPC later only when the player keeps engaging them."
Expand All @@ -433,6 +433,20 @@ PARLEY_TOOL_RULE="Parley tool rule: when using generate_parley_options, pass an
REWARD_MUTATION_RULE="Reward mutation rule: Do not call award_xp, grant_xp, level_up, or reward-granting mutation tools in this built-app provider proof path. If a moment deserves reward accounting, mention the fictional consequence in final narration and persist only memory/decision context with persist_beat."
OPENING_PERSIST_BEAT_RULE="Opening persist rule: do not call persist_beat during the opening turn. Opening state is recorded by the wrapper after your final reply; persist only on later turns after an actual player move has been resolved."
MOVE_PERSIST_BEAT_RULE="Move persist rule: This is a post-move turn: at least one real player move has been accepted and relayed below. If this beat produced durable memory, decision, or time changes, call persist_beat only after you have resolved the move. Do not put player-facing prose into persist_beat events; the wrapper records your final reply through the engine. When calling persist_beat with memories, each memory must be an object with character_id and fact fields. Do not pass memory strings."
OPENING_PROGRESS_TEXT="The first scene gathers around you; voices, risks, and choices come into focus."
MOVE_PROGRESS_TEXTS=(
"Your choice takes hold; nearby voices, risks, and consequences begin to answer."
"The world turns with your action; the scene shifts toward its answer."
"Your move lands; attention gathers around what changes next."
"Momentum carries through the scene; consequences are beginning to surface."
)

choose_move_progress_text() {
local idx="${1:-0}"
[[ "$idx" =~ ^[0-9]+$ ]] || idx=0
local count="${#MOVE_PROGRESS_TEXTS[@]}"
printf '%s\n' "${MOVE_PROGRESS_TEXTS[$((idx % count))]}"
}
if [ -n "${CLAWDND_PLAY_COMPANIONS//[[:space:]]/}" ]; then
COMPANION_TOOL_RULE="Companion rule: only add companions named by CLAWDND_PLAY_COMPANIONS (${CLAWDND_PLAY_COMPANIONS}). Do not add any other companion to the party."
else
Expand Down Expand Up @@ -466,14 +480,19 @@ echo " Save dir: $RUN_DIR"
MCURSOR="$(wc -l < "$MOVES" 2>/dev/null | tr -d ' ')"
MCURSOR="${MCURSOR:-0}"

if [ -n "${HERO_CAMP//[[:space:]]/}" ]; then
log_engine_narration "$HERO_CAMP" "$OPENING_PROGRESS_TEXT" \
|| echo "[codex-dm-provider] warning: could not record immediate opening progress narration" >&2
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if [ -n "$HERO_CAMP" ]; then
OPENING_PROMPT="$(cat <<EOF
You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLAWDND_WORLD".

Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend.

$LOG_EVENT_TOOL_RULE
$WRAPPER_NARRATION_LOG_RULE
$LIVE_PROGRESS_LOG_RULE
$OPENING_LOG_EVENT_RULE
$STATE_DISCOVERY_RULE
$STARTUP_MUTATION_RULE
Expand Down Expand Up @@ -503,7 +522,7 @@ You are the Dungeon Master for a solo WorldOS / ClawDnD adventure in world "$CLA
Before acting, read skills/dungeon-master/SKILL.md and follow its live-world contract. Use the clawdnd-engine tools as the sole writer of game state, clawdnd-rules for rules grounding, and clawdnd-voice only if needed with the null backend.

$LOG_EVENT_TOOL_RULE
$WRAPPER_NARRATION_LOG_RULE
$LIVE_PROGRESS_LOG_RULE
$OPENING_LOG_EVENT_RULE
$STATE_DISCOVERY_RULE
$STARTUP_MUTATION_RULE
Expand Down Expand Up @@ -546,10 +565,13 @@ while true; do
chatlog player "$PMSG"
ACTIVE_CAMPAIGN_ID="$(discover_active_campaign_id)"
CAMPAIGN_TOOL_HINT="$(campaign_tool_hint "$ACTIVE_CAMPAIGN_ID")"
MOVE_PROGRESS_TEXT="$(choose_move_progress_text "$DM_TURNS")"
log_engine_narration "$ACTIVE_CAMPAIGN_ID" "$MOVE_PROGRESS_TEXT" \
|| echo "[codex-dm-provider] warning: could not record immediate move progress narration" >&2
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ! REPLY="$(codex_dm_turn "You are the Dungeon Master mid-session. Re-ground from the engine state first, then resolve this player move through the engine/rules tools and reply with 2nd-person player-facing narration.

$LOG_EVENT_TOOL_RULE
$WRAPPER_NARRATION_LOG_RULE
$LIVE_PROGRESS_LOG_RULE
$STATE_DISCOVERY_RULE
$CAMPAIGN_TOOL_HINT
$SOCIAL_CHECK_TARGET_RULE
Expand Down
8 changes: 4 additions & 4 deletions servers/engine/tests/test_codex_provider_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,17 +185,17 @@ def test_codex_dm_wrapper_forbids_null_speaker_arguments():
source = DM_SCRIPT.read_text(encoding="utf-8")

assert "LOG_EVENT_TOOL_RULE=" in source
assert "WRAPPER_NARRATION_LOG_RULE=" in source
assert "LIVE_PROGRESS_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 "Do not call log_event for player-facing narration or dialogue in this provider wrapper" in source
assert "do not call log_event for the full opening narration" in source
assert "visible story progress while your turn is still running" 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("$WRAPPER_NARRATION_LOG_RULE") == 3
assert source.count("$LIVE_PROGRESS_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
Expand Down
Loading
Loading