Let the smoke show the whole membrane

A test-only TracingInferencePort wraps any inference port and appends each
request's purpose, model, instructions, input turns, tool catalog, and
output to the file GHOSTLIGHT_SMOKE_TRACE names. The logs redact prose by
design and the store is a temp directory, so this is the one way to read
what the Projector, the Persona, and the Interpreter were each given and
what each returned on a road run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-11 00:41:27 +02:00
commit f3d1904428
3 changed files with 111 additions and 2 deletions

View file

@ -2477,6 +2477,9 @@ mod tests {
/// instead of, or alongside, the connector.
sdk: Option<SdkBinding>,
models: ControllerModels,
/// Where the whole membrane is written, request by request, when the
/// smoke is asked to show it.
trace: Option<PathBuf>,
}
async fn fixture() -> Fixture {
@ -2516,8 +2519,13 @@ mod tests {
operational_agent: "gpt-5.6-terra".into(),
elaborator: "gpt-5.6-terra".into(),
},
trace: None,
});
let inference = open_inference(live.connector, live.sdk, &live.models).unwrap();
let inference: Arc<dyn crate::world::InferencePort> = match live.trace {
Some(path) => Arc::new(crate::world::TracingInferencePort::new(inference, path)),
None => inference,
};
let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap();
let controllers =
ControllerRunner::open(world.clone(), inference, work, live.models).unwrap();
@ -3634,6 +3642,7 @@ mod tests {
operational_agent: env("GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL"),
elaborator: env("GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL"),
},
trace: std::env::var_os("GHOSTLIGHT_SMOKE_TRACE").map(PathBuf::from),
};
let ticks: u32 = env("GHOSTLIGHT_SMOKE_TICKS").parse().unwrap();
let log_path = PathBuf::from(env("GHOSTLIGHT_SMOKE_LOG"));

View file

@ -268,6 +268,106 @@ pub(crate) trait InferencePort: Send + Sync {
}
}
/// Test-only. Wraps any `InferencePort` and appends every prepared request and
/// its output to one file, so a road run can show the whole membrane: what the
/// Projector, the Persona, and the Interpreter were each given and what each
/// returned. Production never constructs it; prose reaches durable state only
/// as receipt-bound evidence, and the logs redact it by design.
#[cfg(test)]
pub(crate) struct TracingInferencePort {
inner: Arc<dyn InferencePort>,
path: PathBuf,
}
#[cfg(test)]
impl TracingInferencePort {
pub(crate) fn new(inner: Arc<dyn InferencePort>, path: PathBuf) -> Self {
Self { inner, path }
}
fn render_request(prepared: &PreparedInference) -> String {
let request = &prepared.invocation.request;
let mut out = format!(
"\n==== {:?} model={} request={}\n",
prepared.purpose, request.model, request.request_id
);
if !request.instructions.is_empty() {
out.push_str(&format!("-- instructions --\n{}\n", request.instructions));
}
out.push_str("-- input --\n");
for item in &request.input {
match item {
CodexInputItem::UserText { text } => out.push_str(&format!("[user]\n{text}\n")),
CodexInputItem::AssistantText { text } => {
out.push_str(&format!("[assistant]\n{text}\n"));
}
CodexInputItem::ToolCall {
name, arguments, ..
} => out.push_str(&format!("[tool call {name}]\n{arguments}\n")),
CodexInputItem::ToolResult { output, .. } => {
out.push_str(&format!("[tool result]\n{output}\n"));
}
}
}
if !request.tools.is_empty() {
out.push_str("-- tools --\n");
for tool in &request.tools {
out.push_str(&format!(
"{}: {}\n{}\n",
tool.name, tool.description, tool.parameters_json
));
}
}
out
}
fn render_output(result: &Result<InferenceOutput, InferenceFault>) -> String {
let mut out = String::from("-- output --\n");
match result {
Ok(output) => {
for event in &output.events {
match event {
InferenceEvent::Text(text) => out.push_str(&format!("[text]\n{text}\n")),
InferenceEvent::ToolCall {
name, arguments, ..
} => out.push_str(&format!("[tool call {name}]\n{arguments}\n")),
}
}
}
Err(fault) => out.push_str(&format!("[fault]\n{}\n", fault.detail)),
}
out
}
}
#[cfg(test)]
#[async_trait]
impl InferencePort for TracingInferencePort {
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
self.inner.prepare(request)
}
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
let rendered = Self::render_request(&request);
let result = self.inner.infer(request).await;
let mut text = rendered;
text.push_str(&Self::render_output(&result));
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
use std::io::Write as _;
let _ = file.write_all(text.as_bytes());
}
result
}
fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box<dyn ToolResultOracle>) {
self.inner.lend_tool_results(prepared, oracle);
}
}
/// Builds a `PreparedInference` outside the real CodexConnector wiring. Exists
/// so a test port defined outside this module (`runtime`'s own spec tests, for
/// the tick driver's concurrency and quarantine behaviour) has one legal way to

View file

@ -66,8 +66,8 @@ pub(crate) use vault::VaultEvidenceSource;
pub(crate) use controllers::{
ControllerWork, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError,
ControllerWorkWrite, InferenceEvent, InferenceFault, InferenceOutput, InferencePort,
InferencePurpose, InferenceRequest, PreparedInference, fixture_inference_events,
fixture_inference_output, fixture_prepared_inference,
InferencePurpose, InferenceRequest, PreparedInference, TracingInferencePort,
fixture_inference_events, fixture_inference_output, fixture_prepared_inference,
};
use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch, Site};