diff --git a/epiphany-core/src/bin/epiphany-phase6-context-smoke.rs b/epiphany-core/src/bin/epiphany-phase6-context-smoke.rs new file mode 100644 index 00000000..0c4b0b65 --- /dev/null +++ b/epiphany-core/src/bin/epiphany-phase6-context-smoke.rs @@ -0,0 +1,529 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use serde_json::Value; +use serde_json::json; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[allow(dead_code)] +#[path = "epiphany-mvp-status.rs"] +mod status_cli; + +const DEFAULT_APP_SERVER: &str = r"C:\Users\Meta\.cargo-target-codex\debug\codex-app-server.exe"; + +fn main() -> Result<()> { + let args = Args::parse()?; + let result = run_smoke(&args)?; + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(()) +} + +#[derive(Debug)] +struct Args { + app_server: PathBuf, + codex_home: PathBuf, + result: PathBuf, + transcript: PathBuf, + stderr: PathBuf, +} + +impl Args { + fn parse() -> Result { + let root = env::current_dir().context("failed to resolve current dir")?; + let mut parsed = Self { + app_server: PathBuf::from(DEFAULT_APP_SERVER), + codex_home: root + .join(".epiphany-smoke") + .join("phase6-context-codex-home"), + result: root + .join(".epiphany-smoke") + .join("phase6-context-smoke-result.json"), + transcript: root + .join(".epiphany-smoke") + .join("phase6-context-smoke-transcript.jsonl"), + stderr: root + .join(".epiphany-smoke") + .join("phase6-context-smoke-server.stderr.log"), + }; + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--app-server" => parsed.app_server = take_path(&mut args, "--app-server")?, + "--codex-home" => parsed.codex_home = take_path(&mut args, "--codex-home")?, + "--result" => parsed.result = take_path(&mut args, "--result")?, + "--transcript" => parsed.transcript = take_path(&mut args, "--transcript")?, + "--stderr" => parsed.stderr = take_path(&mut args, "--stderr")?, + other => return Err(anyhow!("unknown argument: {other}")), + } + } + Ok(parsed) + } +} + +fn run_smoke(args: &Args) -> Result { + let root = env::current_dir().context("failed to resolve current dir")?; + let app_server = status_cli::absolute_path(&args.app_server)?; + if !app_server.exists() { + return Err(anyhow!( + "codex app-server binary not found: {}", + app_server.display() + )); + } + let codex_home = status_cli::absolute_path(&args.codex_home)?; + let result_path = status_cli::absolute_path(&args.result)?; + let transcript_path = status_cli::absolute_path(&args.transcript)?; + let stderr_path = status_cli::absolute_path(&args.stderr)?; + reset_smoke_paths( + &root, + &[ + codex_home.clone(), + result_path.clone(), + transcript_path.clone(), + stderr_path.clone(), + ], + )?; + fs::create_dir_all(&codex_home) + .with_context(|| format!("failed to create {}", codex_home.display()))?; + + let mut client = status_cli::AppServerClient::start( + &app_server, + &codex_home, + &transcript_path, + &stderr_path, + )?; + client.send( + "initialize", + Some(json!({ + "clientInfo": { + "name": "epiphany-phase6-context-smoke", + "title": "Epiphany Phase 6 Context Smoke", + "version": "0.1.0", + }, + "capabilities": {"experimentalApi": true}, + })), + true, + )?; + client.send("initialized", None, false)?; + let started = client.send( + "thread/start", + Some(json!({"cwd": root.join("epiphany-core"), "ephemeral": true})), + true, + )?; + let thread_id = started + .pointer("/thread/id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("thread/start response missing thread.id"))? + .to_string(); + + let missing_notification_start = client.notification_len(); + let missing_response = client.send( + "thread/epiphany/context", + Some(json!({"threadId": thread_id, "graphNodeIds": ["context-surface"]})), + true, + )?; + require( + missing_response.get("threadId").and_then(Value::as_str) == Some(&thread_id), + "context response should echo thread id", + )?; + assert_missing_context(&missing_response)?; + client.require_no_notification( + "thread/epiphany/stateUpdated", + missing_notification_start, + Duration::from_secs(1), + )?; + + let update_notification_start = client.notification_len(); + let update = client.send( + "thread/epiphany/update", + Some(json!({ + "threadId": thread_id, + "expectedRevision": 0, + "patch": context_patch(), + })), + true, + )?; + require( + update.get("revision").and_then(Value::as_u64) == Some(1), + "context smoke patch should advance revision to 1", + )?; + client.wait_for_notification( + "thread/epiphany/stateUpdated", + update_notification_start, + Duration::from_secs(10), + )?; + + let context_notification_start = client.notification_len(); + let ready_response = client.send( + "thread/epiphany/context", + Some(json!({ + "threadId": thread_id, + "graphNodeIds": ["missing-node"], + "graphEdgeIds": ["missing-edge"], + "observationIds": ["obs-context", "missing-observation"], + "evidenceIds": ["ev-context-extra", "missing-evidence"], + })), + true, + )?; + assert_ready_context(&ready_response)?; + client.require_no_notification( + "thread/epiphany/stateUpdated", + context_notification_start, + Duration::from_secs(1), + )?; + + let final_read = client.send( + "thread/read", + Some(json!({"threadId": thread_id, "includeTurns": false})), + true, + )?; + require( + final_read + .pointer("/thread/epiphanyState/revision") + .and_then(Value::as_u64) + == Some(1), + "context reflection should not mutate state revision", + )?; + + let result = json!({ + "threadId": thread_id, + "codexHome": codex_home, + "missingStateStatus": missing_response["stateStatus"], + "readyStateStatus": ready_response["stateStatus"], + "readyRevision": ready_response["stateRevision"], + "architectureNodeIds": ids(&ready_response["context"]["graph"]["architectureNodes"]), + "investigationCheckpointId": ready_response["context"]["investigationCheckpoint"]["checkpoint_id"], + "investigationDisposition": ready_response["context"]["investigationCheckpoint"]["disposition"], + "evidenceIds": ids(&ready_response["context"]["evidence"]), + "contextNotificationCount": client.notification_count( + "thread/epiphany/stateUpdated", + context_notification_start, + ), + "finalReadRevision": final_read["thread"]["epiphanyState"]["revision"], + }); + write_json(&result_path, &result)?; + Ok(result) +} + +fn context_patch() -> Value { + let code_ref = json!({ + "path": "app-server/src/codex_message_processor.rs", + "start_line": 10791, + "end_line": 10989, + "symbol": "map_epiphany_context" + }); + json!({ + "objective": "Expose a read-only Epiphany context shard without returning the full state.", + "activeSubgoalId": "phase6-context-smoke", + "investigationCheckpoint": { + "checkpoint_id": "phase6-context-investigation", + "kind": "source_gathering", + "disposition": "regather_required", + "focus": "Make stale planning obvious when context is re-read after compaction.", + "summary": "Context reflection should expose the full durable checkpoint packet.", + "next_action": "Re-gather source before editing if the packet no longer matches reality.", + "captured_at_turn_id": "turn-phase6-context", + "open_questions": ["How much checkpoint detail belongs in scene versus context?"], + "code_refs": [code_ref.clone()], + "evidence_ids": ["ev-context-linked"] + }, + "subgoals": [{ + "id": "phase6-context-smoke", + "title": "Live-smoke context shard reflection", + "status": "active", + "summary": "The app-server context surface should return targeted graph/evidence state." + }], + "graphs": { + "architecture": { + "nodes": [{ + "id": "context-surface", + "title": "Context shard surface", + "purpose": "Return bounded state context for clients without becoming a writer.", + "code_refs": [code_ref.clone()] + }], + "edges": [{ + "id": "context-edge", + "source_id": "context-surface", + "target_id": "context-surface", + "kind": "reflects", + "code_refs": [code_ref.clone()] + }] + }, + "dataflow": { + "nodes": [{ + "id": "typed-state", + "title": "Typed Epiphany state", + "purpose": "Remain authoritative while context shards reflect a slice." + }] + }, + "links": [{ + "dataflow_node_id": "typed-state", + "architecture_node_id": "context-surface", + "relationship": "bounded-reflection" + }] + }, + "graphFrontier": { + "active_node_ids": ["context-surface"], + "active_edge_ids": ["context-edge"] + }, + "graphCheckpoint": { + "checkpoint_id": "phase6-context-smoke", + "graph_revision": 1, + "summary": "Context shard reflection is the active Phase 6 smoke target.", + "frontier_node_ids": ["context-surface"] + }, + "evidence": [ + { + "id": "ev-context-linked", + "kind": "smoke-test", + "status": "ok", + "summary": "Context smoke linked evidence should come along with the observation.", + "code_refs": [code_ref.clone()] + }, + { + "id": "ev-context-extra", + "kind": "review", + "status": "ok", + "summary": "Context smoke direct evidence should be returned when requested.", + "code_refs": [code_ref] + } + ], + "observations": [{ + "id": "obs-context", + "summary": "Context smoke observation should be selected by id.", + "source_kind": "smoke", + "status": "ok", + "code_refs": [{ + "path": "app-server/src/codex_message_processor.rs", + "start_line": 10791, + "end_line": 10989, + "symbol": "map_epiphany_context" + }], + "evidence_ids": ["ev-context-linked"] + }] + }) +} + +fn assert_missing_context(response: &Value) -> Result<()> { + require( + response.get("source").and_then(Value::as_str) == Some("live"), + "missing context should report live source", + )?; + require( + response.get("stateStatus").and_then(Value::as_str) == Some("missing"), + "missing context should report missing state", + )?; + require( + response.get("stateRevision").is_none(), + "missing context should not invent a revision", + )?; + require( + response.pointer("/context/graph") == Some(&json!({})), + "missing context should not invent graph records", + )?; + require( + string_array_eq( + response + .pointer("/missing/graphNodeIds") + .and_then(Value::as_array), + &["context-surface"], + ), + "missing context should echo requested missing node ids", + ) +} + +fn assert_ready_context(response: &Value) -> Result<()> { + require( + response.get("source").and_then(Value::as_str) == Some("live"), + "ready context should report live source", + )?; + require( + response.get("stateStatus").and_then(Value::as_str) == Some("ready"), + "ready context should report ready state", + )?; + require( + response.get("stateRevision").and_then(Value::as_u64) == Some(1), + "ready context should preserve state revision identity", + )?; + require( + ids_eq( + response + .pointer("/context/graph/architectureNodes") + .and_then(Value::as_array), + &["context-surface"], + ), + "context should include active frontier architecture node", + )?; + require( + ids_eq( + response + .pointer("/context/graph/architectureEdges") + .and_then(Value::as_array), + &["context-edge"], + ), + "context should include active frontier architecture edge", + )?; + require( + response + .pointer("/context/graph/links/0/architecture_node_id") + .and_then(Value::as_str) + == Some("context-surface"), + "context should include links touching selected graph nodes", + )?; + require( + string_array_eq( + response + .pointer("/context/frontier/active_node_ids") + .and_then(Value::as_array), + &["context-surface"], + ), + "context should include frontier when active frontier is requested by default", + )?; + require( + response + .pointer("/context/checkpoint/checkpoint_id") + .and_then(Value::as_str) + == Some("phase6-context-smoke"), + "context should expose the current graph checkpoint", + )?; + require( + response + .pointer("/context/investigationCheckpoint/checkpoint_id") + .and_then(Value::as_str) + == Some("phase6-context-investigation"), + "context should expose the investigation checkpoint id", + )?; + require( + response + .pointer("/context/investigationCheckpoint/disposition") + .and_then(Value::as_str) + == Some("regather_required"), + "context should expose the checkpoint disposition", + )?; + require( + ids_eq( + response + .pointer("/context/observations") + .and_then(Value::as_array), + &["obs-context"], + ), + "context should include requested observation", + )?; + require( + ids_eq( + response + .pointer("/context/evidence") + .and_then(Value::as_array), + &["ev-context-linked", "ev-context-extra"], + ), + "context should include linked and directly requested evidence", + )?; + require( + string_array_eq( + response + .pointer("/missing/graphNodeIds") + .and_then(Value::as_array), + &["missing-node"], + ) && string_array_eq( + response + .pointer("/missing/graphEdgeIds") + .and_then(Value::as_array), + &["missing-edge"], + ) && string_array_eq( + response + .pointer("/missing/observationIds") + .and_then(Value::as_array), + &["missing-observation"], + ) && string_array_eq( + response + .pointer("/missing/evidenceIds") + .and_then(Value::as_array), + &["missing-evidence"], + ), + "context should report unresolved requested ids", + ) +} + +fn ids(value: &Value) -> Value { + json!( + value + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>() + }) + .unwrap_or_default() + ) +} + +fn ids_eq(values: Option<&Vec>, expected: &[&str]) -> bool { + values.is_some_and(|values| { + values.len() == expected.len() + && values + .iter() + .zip(expected) + .all(|(value, expected)| value.get("id").and_then(Value::as_str) == Some(*expected)) + }) +} + +fn string_array_eq(values: Option<&Vec>, expected: &[&str]) -> bool { + values.is_some_and(|values| { + values.len() == expected.len() + && values + .iter() + .zip(expected) + .all(|(value, expected)| value.as_str() == Some(*expected)) + }) +} + +fn reset_smoke_paths(root: &Path, paths: &[PathBuf]) -> Result<()> { + let smoke_root = root.join(".epiphany-smoke").canonicalize().or_else(|_| { + let smoke_root = root.join(".epiphany-smoke"); + fs::create_dir_all(&smoke_root)?; + smoke_root.canonicalize() + })?; + for path in paths { + if !path.exists() { + continue; + } + let resolved = path.canonicalize()?; + if resolved == smoke_root || !resolved.starts_with(&smoke_root) { + return Err(anyhow!( + "refusing to delete non-smoke path: {}", + path.display() + )); + } + if path.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn write_json(path: &Path, value: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, format!("{}\n", serde_json::to_string_pretty(value)?))?; + Ok(()) +} + +fn require(condition: bool, message: &str) -> Result<()> { + if condition { + Ok(()) + } else { + Err(anyhow!("{message}")) + } +} + +fn take_path(args: &mut impl Iterator, name: &str) -> Result { + args.next() + .map(PathBuf::from) + .ok_or_else(|| anyhow!("{name} requires a value")) +} diff --git a/notes/epiphany-current-algorithmic-map.md b/notes/epiphany-current-algorithmic-map.md index 09701814..fa04dbd3 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -1504,7 +1504,7 @@ Current tests cover the landed flows at useful seams: - live app-server smoke for the first Phase 6 jobs boundary in `tools/epiphany_phase6_jobs_smoke.py`. - live app-server smoke for the first Phase 6 freshness boundary through native `epiphany-phase6-freshness-smoke`. - live app-server smoke for watcher-backed invalidation inputs inside the Phase 6 freshness boundary in `tools/epiphany_phase6_invalidation_smoke.py`. -- live app-server smoke for the first Phase 6 context boundary in `tools/epiphany_phase6_context_smoke.py`. +- live app-server smoke for the first Phase 6 context boundary through native `epiphany-phase6-context-smoke`. - live app-server smoke for the first Phase 6 graph traversal boundary in `tools/epiphany_phase6_graph_query_smoke.py`. - live app-server smoke for the first Phase 6 planning boundary in `tools/epiphany_phase6_planning_smoke.py`. - live app-server smoke for the first Phase 6 pressure boundary through native `epiphany-phase6-pressure-smoke`. @@ -1528,7 +1528,7 @@ $env:CARGO_TARGET_DIR='C:\Users\Meta\.cargo-target-codex'; cargo test -p codex-a & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_jobs_smoke.py' cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-freshness-smoke & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_invalidation_smoke.py' -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_context_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-context-smoke & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_graph_query_smoke.py' & 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_planning_smoke.py' cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-pressure-smoke diff --git a/notes/epiphany-fork-implementation-plan.md b/notes/epiphany-fork-implementation-plan.md index 22853537..a3df33de 100644 --- a/notes/epiphany-fork-implementation-plan.md +++ b/notes/epiphany-fork-implementation-plan.md @@ -66,7 +66,7 @@ The landed machine now has: - live Phase 6 freshness app-server smoke coverage through native `epiphany-phase6-freshness-smoke` - live Phase 6 watcher-backed invalidation smoke coverage in `tools/epiphany_phase6_invalidation_smoke.py` - read-only Phase 6 targeted state-shard reflection through `thread/epiphany/context` -- live Phase 6 context app-server smoke coverage in `tools/epiphany_phase6_context_smoke.py` +- live Phase 6 context app-server smoke coverage through native `epiphany-phase6-context-smoke` - read-only Phase 6 graph traversal through `thread/epiphany/graphQuery` - live Phase 6 graph traversal smoke coverage in `tools/epiphany_phase6_graph_query_smoke.py` - read-only Phase 6 context-pressure reflection through `thread/epiphany/pressure` @@ -453,7 +453,7 @@ Before modifying watcher-backed invalidation behavior inside freshness reflectio Before modifying targeted context-shard behavior, run: ```powershell -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_context_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-context-smoke ``` Before modifying graph traversal behavior, run: diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b8fa8aac..c730da38 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -147,7 +147,7 @@ The current spine, blessed but not yet finished: - live jobs app-server smoke through `tools/epiphany_phase6_jobs_smoke.py` - live freshness app-server smoke through native `epiphany-phase6-freshness-smoke` - live watcher-backed invalidation smoke through `tools/epiphany_phase6_invalidation_smoke.py` -- live context app-server smoke through `tools/epiphany_phase6_context_smoke.py` +- live context app-server smoke through native `epiphany-phase6-context-smoke` - live graph traversal app-server smoke through `tools/epiphany_phase6_graph_query_smoke.py` - live planning app-server smoke through `tools/epiphany_phase6_planning_smoke.py` - live GUI planning adoption smoke through `tools/epiphany_gui_planning_smoke.py` @@ -228,7 +228,7 @@ For watcher-backed invalidation behavior inside freshness reflection, run: For targeted context-shard behavior changes, run: ```powershell -& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\epiphany_phase6_context_smoke.py' +cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-context-smoke ``` For graph traversal behavior changes, run: diff --git a/state/ledgers.msgpack b/state/ledgers.msgpack index 3f3fa2df..b64aadd7 100644 Binary files a/state/ledgers.msgpack and b/state/ledgers.msgpack differ diff --git a/state/map.yaml b/state/map.yaml index 980d4c7f..a07743a2 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -203,7 +203,7 @@ landed_baseline: - Use `tools/epiphany_phase6_job_control_smoke.py` before modifying launch/interrupt authority over the thin job seam. - Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-freshness-smoke` before modifying freshness reflection behavior. - Use `tools/epiphany_phase6_invalidation_smoke.py` before modifying watcher-backed invalidation behavior inside freshness reflection. - - Use `tools/epiphany_phase6_context_smoke.py` before modifying targeted context-shard behavior. + - Use `cargo run --manifest-path .\epiphany-core\Cargo.toml --bin epiphany-phase6-context-smoke` before modifying targeted context-shard behavior. - Use `tools/epiphany_phase6_graph_query_smoke.py` before modifying graph traversal behavior. - Use `tools/epiphany_phase6_planning_smoke.py` before modifying planning state or the read-only planning projection. - Use `tools/epiphany_gui_planning_smoke.py` before modifying GUI Objective Draft adoption behavior. diff --git a/tools/epiphany_phase6_context_smoke.py b/tools/epiphany_phase6_context_smoke.py deleted file mode 100644 index d4d55cd3..00000000 --- a/tools/epiphany_phase6_context_smoke.py +++ /dev/null @@ -1,328 +0,0 @@ -from __future__ import annotations - -import argparse -import json -from pathlib import Path -from typing import Any - -from epiphany_phase5_smoke import AppServerClient -from epiphany_phase5_smoke import DEFAULT_APP_SERVER -from epiphany_phase5_smoke import ROOT -from epiphany_phase5_smoke import require -from epiphany_phase5_smoke import reset_smoke_paths - - -DEFAULT_CODEX_HOME = ROOT / ".epiphany-smoke" / "phase6-context-codex-home" -DEFAULT_RESULT = ROOT / ".epiphany-smoke" / "phase6-context-smoke-result.json" -DEFAULT_TRANSCRIPT = ROOT / ".epiphany-smoke" / "phase6-context-smoke-transcript.jsonl" -DEFAULT_STDERR = ROOT / ".epiphany-smoke" / "phase6-context-smoke-server.stderr.log" - -CONTEXT_CODE_REF = { - "path": "app-server/src/codex_message_processor.rs", - "start_line": 10791, - "end_line": 10989, - "symbol": "map_epiphany_context", -} - - -def context_patch() -> dict[str, Any]: - return { - "objective": "Expose a read-only Epiphany context shard without returning the full state.", - "activeSubgoalId": "phase6-context-smoke", - "investigationCheckpoint": { - "checkpoint_id": "phase6-context-investigation", - "kind": "source_gathering", - "disposition": "regather_required", - "focus": "Make stale planning obvious when context is re-read after compaction.", - "summary": "Context reflection should expose the full durable checkpoint packet.", - "next_action": "Re-gather source before editing if the packet no longer matches reality.", - "captured_at_turn_id": "turn-phase6-context", - "open_questions": ["How much checkpoint detail belongs in scene versus context?"], - "code_refs": [CONTEXT_CODE_REF], - "evidence_ids": ["ev-context-linked"], - }, - "subgoals": [ - { - "id": "phase6-context-smoke", - "title": "Live-smoke context shard reflection", - "status": "active", - "summary": "The app-server context surface should return targeted graph/evidence state.", - } - ], - "graphs": { - "architecture": { - "nodes": [ - { - "id": "context-surface", - "title": "Context shard surface", - "purpose": "Return bounded state context for clients without becoming a writer.", - "code_refs": [CONTEXT_CODE_REF], - } - ], - "edges": [ - { - "id": "context-edge", - "source_id": "context-surface", - "target_id": "context-surface", - "kind": "reflects", - "code_refs": [CONTEXT_CODE_REF], - } - ], - }, - "dataflow": { - "nodes": [ - { - "id": "typed-state", - "title": "Typed Epiphany state", - "purpose": "Remain authoritative while context shards reflect a slice.", - } - ] - }, - "links": [ - { - "dataflow_node_id": "typed-state", - "architecture_node_id": "context-surface", - "relationship": "bounded-reflection", - } - ], - }, - "graphFrontier": { - "active_node_ids": ["context-surface"], - "active_edge_ids": ["context-edge"], - }, - "graphCheckpoint": { - "checkpoint_id": "phase6-context-smoke", - "graph_revision": 1, - "summary": "Context shard reflection is the active Phase 6 smoke target.", - "frontier_node_ids": ["context-surface"], - }, - "evidence": [ - { - "id": "ev-context-linked", - "kind": "smoke-test", - "status": "ok", - "summary": "Context smoke linked evidence should come along with the observation.", - "code_refs": [CONTEXT_CODE_REF], - }, - { - "id": "ev-context-extra", - "kind": "review", - "status": "ok", - "summary": "Context smoke direct evidence should be returned when requested.", - "code_refs": [CONTEXT_CODE_REF], - }, - ], - "observations": [ - { - "id": "obs-context", - "summary": "Context smoke observation should be selected by id.", - "source_kind": "smoke", - "status": "ok", - "code_refs": [CONTEXT_CODE_REF], - "evidence_ids": ["ev-context-linked"], - } - ], - } - - -def assert_missing_context(response: dict[str, Any]) -> None: - require(response["source"] == "live", "missing context should report live source") - require(response["stateStatus"] == "missing", "missing context should report missing state") - require("stateRevision" not in response, "missing context should not invent a revision") - require( - response["context"]["graph"] == {}, - "missing context should not invent graph records", - ) - require( - response["missing"]["graphNodeIds"] == ["context-surface"], - "missing context should echo requested missing node ids", - ) - - -def assert_ready_context(response: dict[str, Any]) -> None: - require(response["source"] == "live", "ready context should report live source") - require(response["stateStatus"] == "ready", "ready context should report ready state") - require(response["stateRevision"] == 1, "ready context should preserve state revision identity") - require( - [node["id"] for node in response["context"]["graph"]["architectureNodes"]] - == ["context-surface"], - "context should include active frontier architecture node", - ) - require( - [edge["id"] for edge in response["context"]["graph"]["architectureEdges"]] - == ["context-edge"], - "context should include active frontier architecture edge", - ) - require( - response["context"]["graph"]["links"][0]["architecture_node_id"] == "context-surface", - "context should include links touching selected graph nodes", - ) - require( - response["context"]["frontier"]["active_node_ids"] == ["context-surface"], - "context should include frontier when active frontier is requested by default", - ) - require( - response["context"]["checkpoint"]["checkpoint_id"] == "phase6-context-smoke", - "context should expose the current graph checkpoint", - ) - require( - response["context"]["investigationCheckpoint"]["checkpoint_id"] - == "phase6-context-investigation", - "context should expose the investigation checkpoint id", - ) - require( - response["context"]["investigationCheckpoint"]["disposition"] == "regather_required", - "context should expose the checkpoint disposition", - ) - require( - [record["id"] for record in response["context"]["observations"]] == ["obs-context"], - "context should include requested observation", - ) - require( - [record["id"] for record in response["context"]["evidence"]] - == ["ev-context-linked", "ev-context-extra"], - "context should include linked and directly requested evidence", - ) - require( - response["missing"] == { - "graphNodeIds": ["missing-node"], - "graphEdgeIds": ["missing-edge"], - "observationIds": ["missing-observation"], - "evidenceIds": ["missing-evidence"], - }, - "context should report unresolved requested ids", - ) - - -def run_smoke(args: argparse.Namespace) -> dict[str, Any]: - app_server = args.app_server.resolve() - if not app_server.exists(): - raise FileNotFoundError(f"codex app-server binary not found: {app_server}") - - codex_home = args.codex_home.resolve() - result_path = args.result.resolve() - transcript_path = args.transcript.resolve() - stderr_path = args.stderr.resolve() - reset_smoke_paths(codex_home, result_path, transcript_path, stderr_path) - - with AppServerClient(app_server, codex_home, transcript_path, stderr_path) as client: - client.send( - "initialize", - { - "clientInfo": { - "name": "epiphany-phase6-context-smoke", - "title": "Epiphany Phase 6 Context Smoke", - "version": "0.1.0", - }, - "capabilities": {"experimentalApi": True}, - }, - ) - client.send("initialized", expect_response=False) - started = client.send( - "thread/start", - {"cwd": str(ROOT / "epiphany-core"), "ephemeral": True}, - ) - assert started is not None - thread_id = started["thread"]["id"] - - missing_notification_start = len(client.notifications) - missing_response = client.send( - "thread/epiphany/context", - {"threadId": thread_id, "graphNodeIds": ["context-surface"]}, - ) - assert missing_response is not None - require( - missing_response["threadId"] == thread_id, - "context response should echo thread id", - ) - assert_missing_context(missing_response) - client.require_no_notification( - "thread/epiphany/stateUpdated", - start_index=missing_notification_start, - ) - - update_notification_start = len(client.notifications) - update = client.send( - "thread/epiphany/update", - {"threadId": thread_id, "expectedRevision": 0, "patch": context_patch()}, - ) - assert update is not None - require(update["revision"] == 1, "context smoke patch should advance revision to 1") - client.wait_for_notification( - "thread/epiphany/stateUpdated", - start_index=update_notification_start, - ) - - context_notification_start = len(client.notifications) - ready_response = client.send( - "thread/epiphany/context", - { - "threadId": thread_id, - "graphNodeIds": ["missing-node"], - "graphEdgeIds": ["missing-edge"], - "observationIds": ["obs-context", "missing-observation"], - "evidenceIds": ["ev-context-extra", "missing-evidence"], - }, - ) - assert ready_response is not None - assert_ready_context(ready_response) - client.require_no_notification( - "thread/epiphany/stateUpdated", - start_index=context_notification_start, - ) - - final_read = client.send("thread/read", {"threadId": thread_id, "includeTurns": False}) - assert final_read is not None - require( - final_read["thread"]["epiphanyState"]["revision"] == 1, - "context reflection should not mutate state revision", - ) - - result = { - "threadId": thread_id, - "codexHome": str(codex_home), - "missingStateStatus": missing_response["stateStatus"], - "readyStateStatus": ready_response["stateStatus"], - "readyRevision": ready_response["stateRevision"], - "architectureNodeIds": [ - node["id"] for node in ready_response["context"]["graph"]["architectureNodes"] - ], - "investigationCheckpointId": ready_response["context"]["investigationCheckpoint"][ - "checkpoint_id" - ], - "investigationDisposition": ready_response["context"]["investigationCheckpoint"][ - "disposition" - ], - "evidenceIds": [record["id"] for record in ready_response["context"]["evidence"]], - "contextNotificationCount": client.count_notifications( - "thread/epiphany/stateUpdated", - start_index=context_notification_start, - ), - "finalReadRevision": final_read["thread"]["epiphanyState"]["revision"], - } - - result_path.parent.mkdir(parents=True, exist_ok=True) - result_path.write_text( - json.dumps(result, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) - return result - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Live-smoke the Phase 6 Epiphany context shard surface." - ) - parser.add_argument("--app-server", type=Path, default=DEFAULT_APP_SERVER) - parser.add_argument("--codex-home", type=Path, default=DEFAULT_CODEX_HOME) - parser.add_argument("--result", type=Path, default=DEFAULT_RESULT) - parser.add_argument("--transcript", type=Path, default=DEFAULT_TRANSCRIPT) - parser.add_argument("--stderr", type=Path, default=DEFAULT_STDERR) - args = parser.parse_args() - result = run_smoke(args) - print(json.dumps(result, indent=2, ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())