diff --git a/desktop/electron/renderer/src/components/StacksPage.jsx b/desktop/electron/renderer/src/components/StacksPage.jsx
index c392bea..73348af 100644
--- a/desktop/electron/renderer/src/components/StacksPage.jsx
+++ b/desktop/electron/renderer/src/components/StacksPage.jsx
@@ -95,66 +95,95 @@ export default function StacksPage({ onUnavailable, sessionId }) {
}
function StackCall({ stack }) {
+ const request = stack.request ?? {};
+ const response = stack.response ?? {};
+ const tokens = tokenSummary(response.tokenUsage);
+
return (
Call {stack.sequence + 1}
- {stack.status} - {formatDuration(stack.durationMs)} -{" "}
+ {stack.status} · {formatDuration(stack.durationMs)} ·{" "}
{formatTime(stack.startedAtMs)}
-
- {shortId(stack.runId)}
-
+
+ {request.model ? (
+ {request.model}
+ ) : null}
+ {Number.isFinite(response.statusCode) ? (
+
+ HTTP {response.statusCode}
+
+ ) : null}
+ {tokens ? {tokens} : null}
+
+ {shortId(stack.runId)}
+
+
-
- {stack.layers.map((layer, index) => (
-
- ))}
-
+
+
);
}
-function StackLayer({ index, layer }) {
+function RequestSection({ request }) {
+ // Serialize the body only once the user opens the section: a session can hold
+ // hundreds of calls, each carrying the full conversation context, so eagerly
+ // pretty-printing every body on first paint is wasted work.
+ const [expanded, setExpanded] = useState(false);
+ const hasBody = request.body !== undefined && request.body !== null;
+ const meta = [request.api, request.url].filter(Boolean).join(" · ");
+
return (
-
-
-
-
- {String(index + 1).padStart(2, "0")}
-
- {layer.title}
- {layer.status}
-
- {layer.summary}
- {layer.entries?.length ? : null}
- {layer.text ? {layer.text} : null}
- {layer.json !== undefined ? (
- {stringifyJson(layer.json)}
- ) : null}
-
-
+ setExpanded(event.currentTarget.open)}
+ >
+
+ Request
+ {meta ? {meta} : null}
+
+ {expanded && hasBody ? (
+ {stringifyJson(request.body)}
+ ) : null}
+ {!hasBody ? (
+ No request body captured.
+ ) : null}
+
);
}
-function EntryGrid({ entries }) {
+function ResponseSection({ response }) {
+ // Defer body serialization until the section is opened (see RequestSection).
+ const [expanded, setExpanded] = useState(false);
+ const hasBody = response.body !== undefined && response.body !== null;
+
return (
-
- {entries.map((entry) => (
-
-
- {entry.label}
- - {entry.value}
-
- ))}
-
+ setExpanded(event.currentTarget.open)}
+ >
+
+ Response
+ {Number.isFinite(response.statusCode) ? (
+ HTTP {response.statusCode}
+ ) : null}
+
+ {response.error ? (
+ {response.error}
+ ) : null}
+ {expanded && hasBody ? (
+ {stringifyJson(response.body)}
+ ) : null}
+ {!hasBody && !response.error ? (
+ No response body captured.
+ ) : null}
+
);
}
@@ -175,14 +204,21 @@ function emptyStackText(reason) {
}
}
-function defaultOpen(layer) {
- return [
- "system_prompt",
- "session_history",
- "provider_payload",
- "normalized_response",
- "carried_forward",
- ].includes(layer.kind);
+function tokenSummary(usage) {
+ if (!usage || typeof usage !== "object") {
+ return "";
+ }
+ const parts = [];
+ if (Number.isFinite(usage.input)) {
+ parts.push(`${usage.input} in`);
+ }
+ if (Number.isFinite(usage.output)) {
+ parts.push(`${usage.output} out`);
+ }
+ if (Number.isFinite(usage.total)) {
+ parts.push(`${usage.total} total`);
+ }
+ return parts.join(" / ");
}
function stringifyJson(value) {
diff --git a/desktop/electron/renderer/styles.css b/desktop/electron/renderer/styles.css
index 891bf3d..3ba5027 100644
--- a/desktop/electron/renderer/styles.css
+++ b/desktop/electron/renderer/styles.css
@@ -605,6 +605,17 @@ h1 {
line-height: 1.4;
}
+.stack-call-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.stack-call-model,
+.stack-call-status-code,
+.stack-call-tokens,
.stack-run-id {
flex: 0 0 auto;
padding: 3px 6px;
@@ -615,124 +626,88 @@ h1 {
line-height: 1.2;
}
-.stack-layer-list {
- display: grid;
- margin: 0;
- padding: 0;
- list-style: none;
+.stack-call-model {
+ color: var(--ink-dim);
+ font-weight: 700;
}
-.stack-layer {
- border-bottom: 1px solid var(--border);
+.stack-call-tokens {
+ font-variant-numeric: tabular-nums;
}
-.stack-layer:last-child {
- border-bottom: 0;
+.stack-call-failed .stack-call-status-code {
+ border-color: var(--danger);
+ background: var(--danger-tint);
+ color: var(--danger);
}
-.stack-layer details {
- padding: 0;
+.stack-section {
+ border-bottom: 1px solid var(--border);
}
-.stack-layer summary {
- display: grid;
- grid-template-columns: 36px minmax(0, 1fr) auto;
- align-items: center;
+.stack-section:last-child {
+ border-bottom: 0;
+}
+
+.stack-section > summary {
+ display: flex;
+ align-items: baseline;
gap: 12px;
- min-height: 46px;
+ min-height: 44px;
padding: 10px 16px;
cursor: pointer;
list-style: none;
}
-.stack-layer summary::-webkit-details-marker {
+.stack-section > summary::-webkit-details-marker {
display: none;
}
-.stack-layer summary:hover {
+.stack-section > summary:hover {
background: var(--surface-muted);
}
-.stack-layer-index {
- color: var(--ink-faint);
- font-size: 11px;
- line-height: 1;
- font-variant-numeric: tabular-nums;
+.stack-section > summary:focus-visible {
+ outline: none;
+ background: var(--surface-muted);
+ box-shadow: inset 0 0 0 2px var(--active);
}
-.stack-layer-title {
- min-width: 0;
- overflow: hidden;
+.stack-section-title {
+ flex: 0 0 auto;
color: var(--ink-dim);
font-size: 13px;
font-weight: 700;
line-height: 1.35;
- text-overflow: ellipsis;
- white-space: nowrap;
}
-.stack-layer-status {
+.stack-section-meta {
+ min-width: 0;
+ overflow: hidden;
color: var(--ink-faint);
+ font-family: var(--font-mono);
font-size: 11px;
- line-height: 1;
-}
-
-.stack-layer-error .stack-layer-status,
-.stack-layer-error .stack-layer-title {
- color: var(--danger);
+ line-height: 1.3;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.stack-layer-summary {
- margin: 0;
- padding: 0 16px 12px 64px;
+.stack-section-empty {
+ margin: 0 16px 14px;
color: var(--ink-faint);
font-size: 12px;
line-height: 1.45;
}
-.stack-entry-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: 1px;
- margin: 0 16px 12px 64px;
- padding: 1px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--border);
- overflow: hidden;
-}
-
-.stack-entry {
- min-width: 0;
- padding: 8px 10px;
- background: var(--surface-raised);
-}
-
-.stack-entry dt {
- margin: 0 0 4px;
- color: var(--ink-faint);
- font-size: 10.5px;
- line-height: 1.25;
- text-transform: uppercase;
-}
-
-.stack-entry dd {
- margin: 0;
- overflow-wrap: anywhere;
- color: var(--ink-dim);
- font-size: 12px;
- line-height: 1.4;
-}
-
-.stack-text,
+.stack-error-body,
.stack-json {
- max-height: 360px;
- margin: 0 16px 14px 64px;
+ max-height: 420px;
+ margin: 0 16px 14px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-muted);
- color: var(--ink-dim);
+ color: var(--ink);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
@@ -740,8 +715,12 @@ h1 {
white-space: pre;
}
-.stack-json {
- color: var(--ink);
+.stack-error-body {
+ border-color: var(--danger);
+ background: var(--danger-tint);
+ color: var(--danger);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
}
.message-list {
diff --git a/desktop/electron/renderer/window.d.ts b/desktop/electron/renderer/window.d.ts
index 1d29b55..44321c7 100644
--- a/desktop/electron/renderer/window.d.ts
+++ b/desktop/electron/renderer/window.d.ts
@@ -71,15 +71,18 @@ declare global {
status: string;
startedAtMs: number;
durationMs: number;
- layers: Array<{
- kind: string;
- title: string;
- status: string;
- summary: string;
- entries: Array<{ label: string; value: string }>;
- text?: string;
- json?: unknown;
- }>;
+ request: {
+ api: string;
+ url: string;
+ model: string;
+ body?: unknown;
+ };
+ response: {
+ statusCode?: number;
+ body?: unknown;
+ error?: string;
+ tokenUsage?: unknown;
+ };
}>;
unavailableReason?: string;
}>;
diff --git a/src/agent.rs b/src/agent.rs
index 22862dc..263e0ed 100644
--- a/src/agent.rs
+++ b/src/agent.rs
@@ -14,12 +14,9 @@ use uuid::Uuid;
use crate::context::ModelContext;
use crate::model::{
- ChatMessage, ChatModel, ModelError, ModelResponse, ProviderCallTrace, ResponseReasoningItem,
- ToolCall, ToolDef,
-};
-use crate::stacks::{
- ModelCallStack, ModelCallStackInput, SystemPromptTrace, build_model_call_stack,
+ ChatMessage, ChatModel, ModelError, ProviderCallTrace, ResponseReasoningItem, ToolCall,
};
+use crate::stacks::{ModelCallStack, ModelCallStackInput, build_model_call_stack};
use crate::system_prompt::{self, BuildSystemPromptOptions};
use crate::tokens::TokenUsage;
use crate::tools::{CancelFlag, Registry};
@@ -77,8 +74,9 @@ impl Agent {
/// Build the system prompt for this run from the toolset, workspace, and any
/// project context files. Rebuilt per run so the date and project context
- /// stay current.
- fn system_prompt(&self, workspace: &Path) -> SystemPromptTrace {
+ /// stay current. It rides ahead of the conversation and is captured verbatim
+ /// in the request body of every model-call record.
+ fn system_prompt(&self, workspace: &Path) -> String {
let tool_snippets = self.registry.prompt_snippets();
let prompt_guidelines = self.registry.prompt_guidelines();
let selected_tools = self.registry.tool_names();
@@ -87,21 +85,14 @@ impl Agent {
system_prompt::nav_agent_dir().as_deref(),
);
let date = system_prompt::current_date();
- let prompt = system_prompt::build_system_prompt(&BuildSystemPromptOptions {
+ system_prompt::build_system_prompt(&BuildSystemPromptOptions {
selected_tools: &selected_tools,
tool_snippets: &tool_snippets,
prompt_guidelines: &prompt_guidelines,
cwd: workspace,
context_files: &context_files,
date: &date,
- });
- SystemPromptTrace {
- prompt,
- selected_tools,
- context_files,
- cwd: workspace.to_string_lossy().replace('\\', "/"),
- date,
- }
+ })
}
/// Run the model/tool loop from the assembled context for one Run.
@@ -128,9 +119,9 @@ impl Agent {
S: AgentRunSink,
{
let tool_defs = self.registry.defs();
- // Attach the system prompt once; it leads every model call this run.
- let system_prompt = self.system_prompt(workspace);
- context = context.with_system_prompt(system_prompt.prompt.clone());
+ // Attach the system prompt once; it leads every model call this run and
+ // is captured verbatim in each model-call record's request body.
+ context = context.with_system_prompt(self.system_prompt(workspace));
loop {
if cancel.load(Ordering::Relaxed) {
@@ -138,7 +129,6 @@ impl Agent {
}
let active_model = self.active_model();
- let context_before = context.messages().to_vec();
let started_at_ms = now_ms();
let started = Instant::now();
let traced = match active_model.model.respond_with_trace(&context, &tool_defs) {
@@ -149,19 +139,13 @@ impl Agent {
run_id,
started_at_ms,
duration_ms,
- system_prompt: &system_prompt,
- context_before: &context_before,
- tools: &tool_defs,
}
.record(
sink,
ModelCallStackOutcome {
status: "failed",
provider_trace: error.provider_trace.as_deref().cloned(),
- response: None,
token_usage: None,
- context_after: context.messages(),
- steering_messages: Vec::new(),
error: Some(error.message.clone()),
},
)?;
@@ -173,12 +157,8 @@ impl Agent {
run_id,
started_at_ms,
duration_ms,
- system_prompt: &system_prompt,
- context_before: &context_before,
- tools: &tool_defs,
};
let response = traced.response;
- let response_for_stack = response.clone();
let provider_trace = traced.provider_trace;
let usage = response.token_usage.clone().unwrap_or_else(|| {
let input_estimate = active_model
@@ -198,10 +178,7 @@ impl Agent {
ModelCallStackOutcome {
status: "cancelled",
provider_trace,
- response: Some(response_for_stack),
token_usage: Some(usage),
- context_after: context.messages(),
- steering_messages: Vec::new(),
error: Some(
"cancelled after model response before reply emission".to_owned(),
),
@@ -239,10 +216,7 @@ impl Agent {
ModelCallStackOutcome {
status: "completed",
provider_trace,
- response: Some(response_for_stack),
token_usage: Some(usage),
- context_after: context.messages(),
- steering_messages: messages,
error: None,
},
)?;
@@ -254,10 +228,7 @@ impl Agent {
ModelCallStackOutcome {
status: "completed",
provider_trace,
- response: Some(response_for_stack),
token_usage: Some(usage),
- context_after: context.messages(),
- steering_messages: Vec::new(),
error: None,
},
)?;
@@ -326,10 +297,7 @@ impl Agent {
ModelCallStackOutcome {
status: "cancelled",
provider_trace,
- response: Some(response_for_stack),
token_usage: Some(usage),
- context_after: context.messages(),
- steering_messages: Vec::new(),
error: Some("cancelled after tool batch".to_owned()),
},
)?;
@@ -344,10 +312,7 @@ impl Agent {
ModelCallStackOutcome {
status: "completed",
provider_trace,
- response: Some(response_for_stack),
token_usage: Some(usage),
- context_after: context.messages(),
- steering_messages,
error: None,
},
)?;
@@ -359,18 +324,12 @@ struct ModelCallCapture<'a> {
run_id: &'a str,
started_at_ms: u64,
duration_ms: f64,
- system_prompt: &'a SystemPromptTrace,
- context_before: &'a [ChatMessage],
- tools: &'a [ToolDef],
}
-struct ModelCallStackOutcome<'a> {
- status: &'a str,
+struct ModelCallStackOutcome {
+ status: &'static str,
provider_trace: Option,
- response: Option,
token_usage: Option,
- context_after: &'a [ChatMessage],
- steering_messages: Vec,
error: Option,
}
@@ -389,14 +348,8 @@ impl ModelCallCapture<'_> {
status: outcome.status.to_owned(),
started_at_ms: self.started_at_ms,
duration_ms: self.duration_ms,
- system_prompt: self.system_prompt.clone(),
- context_before: self.context_before.to_vec(),
- tools: self.tools.to_vec(),
provider_trace: outcome.provider_trace,
- response: outcome.response,
token_usage: outcome.token_usage,
- context_after: outcome.context_after.to_vec(),
- steering_messages: outcome.steering_messages,
error: outcome.error,
});
sink.model_call_stack(stack).map_err(AgentRunError::Sink)
diff --git a/src/lib.rs b/src/lib.rs
index c8d9100..afcf3f3 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -52,7 +52,7 @@ pub use session::{Event, SendError, SessionStore, Subscription};
pub use stack_store::{
DEFAULT_STACKS_MAX_BYTES, StackAvailability, StackQueryResult, StackStore, StackStoreError,
};
-pub use stacks::{ModelCallStack, StackEntry, StackLayer};
+pub use stacks::{ModelCallRequest, ModelCallResponse, ModelCallStack};
pub use storage::{SessionSummary, Storage, StorageError};
pub use system_prompt::{
BuildSystemPromptOptions, ContextFile, build_system_prompt, load_project_context_files,
diff --git a/src/model.rs b/src/model.rs
index d14b15d..f861480 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -805,7 +805,13 @@ impl ChatModel for OpenAiModel {
body.clone(),
);
+ // `http_status_as_error(false)` keeps ureq from collapsing a 4xx/5xx into
+ // a bare transport error, so the provider's error body stays readable and
+ // is captured into the trace below.
let mut response = ureq::post(&url)
+ .config()
+ .http_status_as_error(false)
+ .build()
.header("Authorization", format!("Bearer {}", self.config.api_key))
.send_json(&body)
.map_err(|error| {
@@ -813,13 +819,7 @@ impl ChatModel for OpenAiModel {
ModelError::new(message.clone())
.with_provider_trace(trace.clone().with_error(&message))
})?;
- trace.status_code = Some(response.status().as_u16());
- trace.request_id = response
- .headers()
- .get("x-request-id")
- .or_else(|| response.headers().get("request-id"))
- .and_then(|value| value.to_str().ok())
- .map(str::to_owned);
+ capture_status_or_error(&mut response, &mut trace)?;
let payload: Value = response.body_mut().read_json().map_err(|error| {
let message = format!("could not read model response: {error}");
@@ -928,8 +928,14 @@ impl ChatModel for OpenAiResponsesModel {
body.clone(),
);
- let mut request =
- ureq::post(&url).header("Authorization", format!("Bearer {}", self.config.api_key));
+ // `http_status_as_error(false)` keeps ureq from collapsing a 4xx/5xx into
+ // a bare transport error, so the provider's error body stays readable and
+ // is captured into the trace below.
+ let mut request = ureq::post(&url)
+ .config()
+ .http_status_as_error(false)
+ .build()
+ .header("Authorization", format!("Bearer {}", self.config.api_key));
if streaming {
request = request.header("Accept", "text/event-stream");
}
@@ -944,13 +950,10 @@ impl ChatModel for OpenAiResponsesModel {
let message = format!("model request failed: {error}");
ModelError::new(message.clone()).with_provider_trace(trace.clone().with_error(&message))
})?;
- trace.status_code = Some(response.status().as_u16());
- trace.request_id = response
- .headers()
- .get("x-request-id")
- .or_else(|| response.headers().get("request-id"))
- .and_then(|value| value.to_str().ok())
- .map(str::to_owned);
+ // A non-2xx never carries the SSE stream, even for the streaming Codex
+ // backend — it returns a JSON error body, which `capture_status_or_error`
+ // captures before we attempt to read the success payload below.
+ capture_status_or_error(&mut response, &mut trace)?;
// Codex auth streams the response as SSE; every other Responses provider
// returns one JSON body. Both reduce to a single payload value or an
@@ -998,6 +1001,76 @@ impl ChatModel for OpenAiResponsesModel {
}
}
+/// Record the response status and request id onto the trace, then turn a non-2xx
+/// into a [`ModelError`] whose message carries the provider's error body (also
+/// captured onto the trace). Returns `Ok(())` on a 2xx so the caller proceeds to
+/// read the success payload. Shared by every HTTP adapter so status handling and
+/// error capture stay identical across providers.
+fn capture_status_or_error(
+ response: &mut ureq::http::Response,
+ trace: &mut ProviderCallTrace,
+) -> Result<(), ModelError> {
+ let status = response.status().as_u16();
+ trace.status_code = Some(status);
+ trace.request_id = response
+ .headers()
+ .get("x-request-id")
+ .or_else(|| response.headers().get("request-id"))
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+
+ if response.status().is_success() {
+ return Ok(());
+ }
+
+ let detail = capture_provider_error_body(response, trace);
+ let message = format!("model request failed: http status: {status}: {detail}");
+ Err(ModelError::new(message.clone()).with_provider_trace(trace.clone().with_error(&message)))
+}
+
+/// Read a non-2xx provider response body, store it on the trace (parsed as JSON
+/// when possible, otherwise as raw text), and return a short detail for the
+/// error message. Capturing the body is the point: the provider's explanation of
+/// *why* a call failed lives here, not in the status line.
+fn capture_provider_error_body(
+ response: &mut ureq::http::Response,
+ trace: &mut ProviderCallTrace,
+) -> String {
+ let text = response.body_mut().read_to_string().unwrap_or_default();
+ match serde_json::from_str::(&text) {
+ Ok(value) => {
+ let detail = extract_provider_error_message(&value).unwrap_or_else(|| text.clone());
+ trace.response_payload = Some(value);
+ detail
+ }
+ Err(_) => {
+ if !text.is_empty() {
+ trace.response_payload = Some(Value::String(text.clone()));
+ }
+ text
+ }
+ }
+}
+
+/// Pull the provider's human-facing error message out of a JSON error body,
+/// trying the common OpenAI-compatible shapes.
+fn extract_provider_error_message(value: &Value) -> Option {
+ if let Some(message) = value
+ .get("error")
+ .and_then(|error| error.get("message"))
+ .and_then(Value::as_str)
+ {
+ return Some(message.to_owned());
+ }
+ if let Some(error) = value.get("error").and_then(Value::as_str) {
+ return Some(error.to_owned());
+ }
+ value
+ .get("message")
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+}
+
fn responses_reasoning_json(config: &OpenAiConfig) -> Option {
if !config.reasoning || !config.supports_reasoning_effort() {
return None;
@@ -1586,8 +1659,17 @@ impl ChatModel for MockModel {
fn respond(
&self,
context: &ModelContext,
- _tools: &[ToolDef],
+ tools: &[ToolDef],
) -> Result {
+ self.respond_with_trace(context, tools)
+ .map(|traced| traced.response)
+ }
+
+ fn respond_with_trace(
+ &self,
+ context: &ModelContext,
+ _tools: &[ToolDef],
+ ) -> Result {
let user_messages: Vec<&str> = context
.messages()
.iter()
@@ -1604,6 +1686,36 @@ impl ChatModel for MockModel {
reply.push_str(&format!(". Earlier you said: \"{}\"", user_messages[0]));
}
- Ok(ModelResponse::text(reply))
+ // Build a representative request/response trace so the stack-capture path
+ // is exercised offline and in tests, mirroring the real adapters: the
+ // request body holds the system prompt and message history, the response
+ // body holds the assembled reply.
+ let mut messages: Vec = Vec::with_capacity(context.messages().len() + 1);
+ if let Some(system_prompt) = context.system_prompt() {
+ messages.push(json!({ "role": "system", "content": system_prompt }));
+ }
+ messages.extend(
+ context
+ .messages()
+ .iter()
+ .map(|message| message_json(message, false)),
+ );
+ let request_payload = json!({ "model": "mock", "messages": messages });
+
+ let mut trace = ProviderCallTrace::new(
+ "mock",
+ "mock://local".to_owned(),
+ "mock".to_owned(),
+ request_payload,
+ );
+ trace.status_code = Some(200);
+ trace.response_payload = Some(json!({
+ "output": [{ "role": "assistant", "content": reply }],
+ }));
+
+ Ok(TracedModelResponse {
+ response: ModelResponse::text(reply),
+ provider_trace: Some(trace),
+ })
}
}
diff --git a/src/stack_store.rs b/src/stack_store.rs
index ac27a5a..41ef699 100644
--- a/src/stack_store.rs
+++ b/src/stack_store.rs
@@ -17,7 +17,9 @@ use uuid::Uuid;
use crate::stacks::ModelCallStack;
pub const DEFAULT_STACKS_MAX_BYTES: u64 = 800 * 1024 * 1024;
-const STACK_RECORD_SCHEMA_VERSION: u32 = 1;
+// v2: faithful per-turn request/response record (replaced the derived layered
+// view). Older v1 records are silently dropped on read.
+const STACK_RECORD_SCHEMA_VERSION: u32 = 2;
#[derive(Debug)]
pub struct StackStoreError(String);
diff --git a/src/stacks.rs b/src/stacks.rs
index 2ba9f84..2c24c9c 100644
--- a/src/stacks.rs
+++ b/src/stacks.rs
@@ -1,16 +1,17 @@
-//! Model-call stack snapshots for the debugging/architecture view.
+//! Per-turn model-call record: exactly what nav sent to the LLM and exactly
+//! what came back.
//!
-//! A stack is captured at the live model-call boundary. It is deliberately
-//! layered instead of being only a raw JSON blob: each layer names what was
-//! available, how it was assembled, and what state moved forward.
+//! One record is captured at each live model-call boundary. The goal is full
+//! clarity for context management — the request body holds everything that was
+//! sent (system prompt, message/input history, tools, reasoning settings), and
+//! the response body holds the assembled provider response (or, on failure, the
+//! captured error body). Nothing here is a derived summary: it is the faithful
+//! wire payload plus the call's status, timing, and token usage.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
-use crate::model::{
- ChatMessage, FinishReason, ModelResponse, ProviderCallTrace, Role, ToolCall, ToolDef,
-};
-use crate::system_prompt::ContextFile;
+use crate::model::ProviderCallTrace;
use crate::tokens::{TokenCountConfidence, TokenCountSource, TokenUsage};
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -19,40 +20,45 @@ pub struct ModelCallStack {
pub id: String,
pub run_id: String,
pub sequence: u64,
+ /// `completed`, `failed`, or `cancelled`.
pub status: String,
pub started_at_ms: u64,
pub duration_ms: f64,
- pub layers: Vec,
+ /// What was sent to the LLM.
+ pub request: ModelCallRequest,
+ /// What came back from the LLM (or the captured failure).
+ pub response: ModelCallResponse,
}
+/// The exact payload sent to the provider for one model call.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
-pub struct StackLayer {
- pub kind: String,
- pub title: String,
- pub status: String,
- pub summary: String,
- pub entries: Vec,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub text: Option,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub json: Option,
-}
-
+pub struct ModelCallRequest {
+ /// Provider API kind, e.g. `openai-completions`, `openai-responses`,
+ /// `codex-responses`.
+ pub api: String,
+ pub url: String,
+ pub model: String,
+ /// The verbatim request body. `None` only for adapters that issue no HTTP
+ /// request (the offline mock would still supply a representative body).
+ pub body: Option,
+}
+
+/// What the provider returned for one model call.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
-pub struct StackEntry {
- pub label: String,
- pub value: String,
-}
-
-#[derive(Clone, Debug)]
-pub(crate) struct SystemPromptTrace {
- pub prompt: String,
- pub selected_tools: Vec,
- pub context_files: Vec,
- pub cwd: String,
- pub date: String,
+pub struct ModelCallResponse {
+ /// HTTP status, when the call reached the provider.
+ pub status_code: Option,
+ /// The response body, parsed as JSON when possible. For a streamed turn
+ /// this is the assembled object; for a non-2xx it is the provider error
+ /// body. `None` when the body was unavailable or not JSON.
+ pub body: Option,
+ /// Failure detail when the call errored — transport failure, a non-2xx
+ /// status (with the body text), or a parse error. `None` on success.
+ pub error: Option,
+ /// Tokens reported or estimated for the turn, for quick scanning.
+ pub token_usage: Option,
}
pub(crate) struct ModelCallStackInput {
@@ -61,40 +67,35 @@ pub(crate) struct ModelCallStackInput {
pub status: String,
pub started_at_ms: u64,
pub duration_ms: f64,
- pub system_prompt: SystemPromptTrace,
- pub context_before: Vec,
- pub tools: Vec,
pub provider_trace: Option,
- pub response: Option,
pub token_usage: Option,
- pub context_after: Vec,
- pub steering_messages: Vec,
pub error: Option,
}
pub(crate) fn build_model_call_stack(input: ModelCallStackInput) -> ModelCallStack {
- let layers = vec![
- system_prompt_layer(&input.system_prompt),
- project_context_layer(&input.system_prompt.context_files),
- session_history_layer(&input.context_before),
- included_tool_activity_layer(&input.context_before),
- tool_definitions_layer(&input.tools),
- assembly_layer(&input),
- steering_layer(&input.steering_messages),
- provider_payload_layer(input.provider_trace.as_ref()),
- raw_response_layer(input.provider_trace.as_ref(), input.error.as_deref()),
- normalized_response_layer(
- input.response.as_ref(),
- input.token_usage.as_ref(),
- input.error.as_deref(),
- ),
- metadata_layer(&input),
- carried_forward_layer(
- &input.context_after,
- &input.steering_messages,
- input.error.as_deref(),
- ),
- ];
+ let trace = input.provider_trace.as_ref();
+
+ let request = match trace {
+ Some(trace) => ModelCallRequest {
+ api: trace.api_kind.clone(),
+ url: trace.url.clone(),
+ model: trace.model_id.clone(),
+ body: Some(trace.request_payload.clone()),
+ },
+ None => ModelCallRequest {
+ api: String::new(),
+ url: String::new(),
+ model: String::new(),
+ body: None,
+ },
+ };
+
+ let response = ModelCallResponse {
+ status_code: trace.and_then(|t| t.status_code),
+ body: trace.and_then(|t| t.response_payload.clone()),
+ error: input.error,
+ token_usage: input.token_usage.as_ref().map(token_usage_json),
+ };
ModelCallStack {
id: input.id,
@@ -103,523 +104,11 @@ pub(crate) fn build_model_call_stack(input: ModelCallStackInput) -> ModelCallSta
status: input.status,
started_at_ms: input.started_at_ms,
duration_ms: input.duration_ms,
- layers,
- }
-}
-
-fn system_prompt_layer(prompt: &SystemPromptTrace) -> StackLayer {
- StackLayer {
- kind: "system_prompt".to_owned(),
- title: "System prompt / developer instructions".to_owned(),
- status: "available".to_owned(),
- summary: format!(
- "{} chars assembled from {} selected tools and {} project context files",
- prompt.prompt.chars().count(),
- prompt.selected_tools.len(),
- prompt.context_files.len()
- ),
- entries: vec![
- entry("Date", &prompt.date),
- entry("Working directory", &prompt.cwd),
- entry("Selected tools", &prompt.selected_tools.join(", ")),
- entry("Prompt bytes", &prompt.prompt.len().to_string()),
- ],
- text: Some(prompt.prompt.clone()),
- json: Some(json!({
- "date": prompt.date,
- "cwd": prompt.cwd,
- "selectedTools": prompt.selected_tools,
- "projectContextFiles": context_files_json(&prompt.context_files),
- "prompt": prompt.prompt,
- })),
- }
-}
-
-fn project_context_layer(files: &[ContextFile]) -> StackLayer {
- if files.is_empty() {
- return StackLayer {
- kind: "project_context".to_owned(),
- title: "Project context files".to_owned(),
- status: "empty".to_owned(),
- summary: "No AGENTS.md or CLAUDE.md files were loaded for this call".to_owned(),
- entries: Vec::new(),
- text: None,
- json: Some(json!([])),
- };
- }
-
- let text = files
- .iter()
- .map(|file| format!("--- {}\n{}", file.path, file.content))
- .collect::>()
- .join("\n\n");
- StackLayer {
- kind: "project_context".to_owned(),
- title: "Project context files".to_owned(),
- status: "available".to_owned(),
- summary: format!(
- "{} context file(s) included before the model call",
- files.len()
- ),
- entries: files
- .iter()
- .map(|file| entry(&file.path, &format!("{} bytes", file.content.len())))
- .collect(),
- text: Some(text),
- json: Some(json!(context_files_json(files))),
- }
-}
-
-fn session_history_layer(messages: &[ChatMessage]) -> StackLayer {
- let counts = role_counts(messages);
- StackLayer {
- kind: "session_history".to_owned(),
- title: "Session history included in request".to_owned(),
- status: if messages.is_empty() {
- "empty"
- } else {
- "available"
- }
- .to_owned(),
- summary: format!(
- "{} message(s): {} user, {} assistant, {} tool",
- messages.len(),
- counts.user,
- counts.assistant,
- counts.tool
- ),
- entries: vec![
- entry("Total messages", &messages.len().to_string()),
- entry("User messages", &counts.user.to_string()),
- entry("Assistant messages", &counts.assistant.to_string()),
- entry("Tool result messages", &counts.tool.to_string()),
- entry("Text bytes", &message_text_bytes(messages).to_string()),
- ],
- text: Some(format_messages(messages)),
- json: Some(json!(messages.iter().map(message_json).collect::>())),
- }
-}
-
-fn included_tool_activity_layer(messages: &[ChatMessage]) -> StackLayer {
- let tool_messages: Vec<&ChatMessage> = messages
- .iter()
- .filter(|message| !message.tool_calls.is_empty() || message.role == Role::Tool)
- .collect();
- let tool_call_count: usize = messages
- .iter()
- .map(|message| message.tool_calls.len())
- .sum();
- let tool_result_count = messages
- .iter()
- .filter(|message| message.role == Role::Tool)
- .count();
-
- StackLayer {
- kind: "included_tool_activity".to_owned(),
- title: "Tool calls and tool results included".to_owned(),
- status: if tool_messages.is_empty() {
- "empty"
- } else {
- "available"
- }
- .to_owned(),
- summary: format!(
- "{} tool call(s) and {} tool result(s) were present in the request context",
- tool_call_count, tool_result_count
- ),
- entries: vec![
- entry("Tool calls", &tool_call_count.to_string()),
- entry("Tool results", &tool_result_count.to_string()),
- ],
- text: Some(format_messages(
- &tool_messages
- .iter()
- .map(|message| (*message).clone())
- .collect::>(),
- )),
- json: Some(json!(
- tool_messages
- .iter()
- .map(|message| message_json(message))
- .collect::>()
- )),
- }
-}
-
-fn tool_definitions_layer(tools: &[ToolDef]) -> StackLayer {
- StackLayer {
- kind: "tool_definitions".to_owned(),
- title: "Tool definitions available to the model".to_owned(),
- status: if tools.is_empty() {
- "empty"
- } else {
- "available"
- }
- .to_owned(),
- summary: format!("{} tool definition(s) advertised", tools.len()),
- entries: tools
- .iter()
- .map(|tool| entry(&tool.name, &tool.description))
- .collect(),
- text: Some(
- tools
- .iter()
- .map(|tool| format!("- {}: {}", tool.name, tool.description))
- .collect::>()
- .join("\n"),
- ),
- json: Some(json!(tools.iter().map(tool_def_json).collect::>())),
+ request,
+ response,
}
}
-fn assembly_layer(input: &ModelCallStackInput) -> StackLayer {
- StackLayer {
- kind: "assembly".to_owned(),
- title: "Compaction, pruning, and omitted context".to_owned(),
- status: "available".to_owned(),
- summary: "The current assembler forwards full stored history; no compaction or pruning ran"
- .to_owned(),
- entries: vec![
- entry(
- "Context assembler",
- "full stored history, preserving order",
- ),
- entry("Messages omitted", "0"),
- entry("Compaction summaries applied", "0"),
- entry("Pruning decisions", "none"),
- entry("Provider adapter", provider_api_kind(input.provider_trace.as_ref())),
- ],
- text: Some(
- "No compaction, ranking, or pruning is currently implemented on this path. The model call received the assembled system prompt, the full in-memory session history, and the currently registered tool definitions."
- .to_owned(),
- ),
- json: Some(json!({
- "omittedMessages": [],
- "compactionSummaries": [],
- "pruningDecisions": [],
- "contextPolicy": "full-history",
- })),
- }
-}
-
-fn steering_layer(messages: &[String]) -> StackLayer {
- StackLayer {
- kind: "mid_run_steering".to_owned(),
- title: "Mid-run steering messages".to_owned(),
- status: if messages.is_empty() {
- "empty"
- } else {
- "available"
- }
- .to_owned(),
- summary: format!(
- "{} steering message(s) folded in after this model call",
- messages.len()
- ),
- entries: messages
- .iter()
- .enumerate()
- .map(|(index, message)| entry(&format!("Message {}", index + 1), message))
- .collect(),
- text: (!messages.is_empty()).then(|| messages.join("\n\n")),
- json: Some(json!(messages)),
- }
-}
-
-fn provider_payload_layer(trace: Option<&ProviderCallTrace>) -> StackLayer {
- let Some(trace) = trace else {
- return StackLayer {
- kind: "provider_payload".to_owned(),
- title: "Final provider payload sent to the LLM".to_owned(),
- status: "unavailable".to_owned(),
- summary: "The active model adapter did not expose a raw provider payload".to_owned(),
- entries: Vec::new(),
- text: None,
- json: None,
- };
- };
-
- let message_count = trace
- .request_payload
- .get("messages")
- .and_then(Value::as_array)
- .map(Vec::len)
- .unwrap_or(0);
- let tool_count = trace
- .request_payload
- .get("tools")
- .and_then(Value::as_array)
- .map(Vec::len)
- .unwrap_or(0);
-
- StackLayer {
- kind: "provider_payload".to_owned(),
- title: "Final provider payload sent to the LLM".to_owned(),
- status: "available".to_owned(),
- summary: format!(
- "{} request message(s), {} tool definition(s), model {}",
- message_count, tool_count, trace.model_id
- ),
- entries: vec![
- entry("API", &trace.api_kind),
- entry("URL", &trace.url),
- entry("Model", &trace.model_id),
- entry("Messages", &message_count.to_string()),
- entry("Tools", &tool_count.to_string()),
- ],
- text: None,
- json: Some(trace.request_payload.clone()),
- }
-}
-
-fn raw_response_layer(trace: Option<&ProviderCallTrace>, error: Option<&str>) -> StackLayer {
- match trace.and_then(|trace| trace.response_payload.as_ref()) {
- Some(payload) => StackLayer {
- kind: "raw_response".to_owned(),
- title: "Raw LLM response".to_owned(),
- status: "available".to_owned(),
- summary: "Raw provider response body captured before normalization".to_owned(),
- entries: Vec::new(),
- text: None,
- json: Some(payload.clone()),
- },
- None if error.is_some() => StackLayer {
- kind: "raw_response".to_owned(),
- title: "Raw LLM response".to_owned(),
- status: "unavailable".to_owned(),
- summary: "The model call failed before a provider response body was available"
- .to_owned(),
- entries: Vec::new(),
- text: error.map(str::to_owned),
- json: None,
- },
- None => StackLayer {
- kind: "raw_response".to_owned(),
- title: "Raw LLM response".to_owned(),
- status: "unavailable".to_owned(),
- summary: "The active model adapter returned only a normalized response".to_owned(),
- entries: Vec::new(),
- text: None,
- json: None,
- },
- }
-}
-
-fn normalized_response_layer(
- response: Option<&ModelResponse>,
- token_usage: Option<&TokenUsage>,
- error: Option<&str>,
-) -> StackLayer {
- let Some(response) = response else {
- return StackLayer {
- kind: "normalized_response".to_owned(),
- title: "Normalized LLM response".to_owned(),
- status: "error".to_owned(),
- summary: error.unwrap_or("model call failed").to_owned(),
- entries: Vec::new(),
- text: error.map(str::to_owned),
- json: None,
- };
- };
-
- let finish_reason = finish_reason_label(&response.finish_reason);
- StackLayer {
- kind: "normalized_response".to_owned(),
- title: "Normalized LLM response".to_owned(),
- status: "available".to_owned(),
- summary: format!(
- "{} finish, {} tool call(s), {}",
- finish_reason,
- response.tool_calls.len(),
- usage_summary(token_usage)
- ),
- entries: vec![
- entry("Finish reason", finish_reason),
- entry("Tool calls", &response.tool_calls.len().to_string()),
- entry(
- "Reasoning content",
- present_label(response.reasoning_content.as_deref()),
- ),
- entry("Token usage", &usage_summary(token_usage)),
- ],
- text: response
- .content
- .clone()
- .or_else(|| response.reasoning_content.clone()),
- json: Some(json!({
- "content": response.content,
- "reasoningContent": response.reasoning_content,
- "toolCalls": response.tool_calls.iter().map(tool_call_json).collect::>(),
- "finishReason": finish_reason,
- "tokenUsage": token_usage.map(token_usage_json),
- })),
- }
-}
-
-fn metadata_layer(input: &ModelCallStackInput) -> StackLayer {
- let trace = input.provider_trace.as_ref();
- let mut entries = vec![
- entry("Run id", &input.run_id),
- entry("Call id", &input.id),
- entry("Status", &input.status),
- entry("Started at", &format!("{} ms", input.started_at_ms)),
- entry("Duration", &format!("{:.2} ms", input.duration_ms)),
- entry("Retries", "0"),
- ];
- if let Some(trace) = trace {
- entries.extend([
- entry("API kind", &trace.api_kind),
- entry("Request URL", &trace.url),
- entry("Configured model", &trace.model_id),
- entry(
- "Provider model",
- optional_label(trace.provider_model_id.as_deref()),
- ),
- entry(
- "Provider response id",
- optional_label(trace.response_id.as_deref()),
- ),
- entry(
- "Provider request id",
- optional_label(trace.request_id.as_deref()),
- ),
- entry(
- "HTTP status",
- &trace
- .status_code
- .map(|status| status.to_string())
- .unwrap_or_else(|| "(unavailable)".to_owned()),
- ),
- ]);
- }
-
- StackLayer {
- kind: "metadata".to_owned(),
- title: "Provider state, timings, and model settings".to_owned(),
- status: "available".to_owned(),
- summary: format!(
- "{} call in {:.2} ms ({})",
- provider_api_kind(trace),
- input.duration_ms,
- input.status
- ),
- entries,
- text: input.error.clone(),
- json: Some(json!({
- "runId": input.run_id,
- "callId": input.id,
- "status": input.status,
- "startedAtMs": input.started_at_ms,
- "durationMs": input.duration_ms,
- "retries": 0,
- "provider": provider_metadata_json(trace),
- "error": input.error,
- })),
- }
-}
-
-fn provider_metadata_json(trace: Option<&ProviderCallTrace>) -> Option {
- trace.map(|trace| {
- json!({
- "apiKind": &trace.api_kind,
- "url": &trace.url,
- "modelId": &trace.model_id,
- "providerModelId": trace.provider_model_id.as_deref(),
- "responseId": trace.response_id.as_deref(),
- "requestId": trace.request_id.as_deref(),
- "statusCode": trace.status_code,
- "error": trace.error.as_deref(),
- "payloadLayers": {
- "request": "provider_payload",
- "response": "raw_response",
- },
- "hasResponsePayload": trace.response_payload.is_some(),
- })
- })
-}
-
-fn carried_forward_layer(
- messages: &[ChatMessage],
- steering_messages: &[String],
- error: Option<&str>,
-) -> StackLayer {
- if let Some(error) = error {
- return StackLayer {
- kind: "carried_forward".to_owned(),
- title: "State carried forward into the next turn".to_owned(),
- status: "error".to_owned(),
- summary: "No new model state was carried forward because the call failed".to_owned(),
- entries: vec![entry("Error", error)],
- text: Some(error.to_owned()),
- json: Some(json!({ "messages": [], "error": error })),
- };
- }
-
- let counts = role_counts(messages);
- StackLayer {
- kind: "carried_forward".to_owned(),
- title: "State carried forward into the next turn".to_owned(),
- status: "available".to_owned(),
- summary: format!(
- "{} message(s) now in context; {} steering message(s) folded in",
- messages.len(),
- steering_messages.len()
- ),
- entries: vec![
- entry("Total messages", &messages.len().to_string()),
- entry("User messages", &counts.user.to_string()),
- entry("Assistant messages", &counts.assistant.to_string()),
- entry("Tool result messages", &counts.tool.to_string()),
- entry(
- "Steering messages folded in",
- &steering_messages.len().to_string(),
- ),
- ],
- text: Some(format_messages(messages)),
- json: Some(json!(messages.iter().map(message_json).collect::>())),
- }
-}
-
-fn context_files_json(files: &[ContextFile]) -> Vec {
- files
- .iter()
- .map(|file| {
- json!({
- "path": file.path,
- "content": file.content,
- "bytes": file.content.len(),
- })
- })
- .collect()
-}
-
-fn message_json(message: &ChatMessage) -> Value {
- json!({
- "role": message.role.as_str(),
- "content": message.content,
- "reasoningContent": message.reasoning_content,
- "toolCalls": message.tool_calls.iter().map(tool_call_json).collect::>(),
- "toolCallId": message.tool_call_id,
- "isError": message.is_error,
- })
-}
-
-fn tool_call_json(call: &ToolCall) -> Value {
- json!({
- "id": call.id,
- "name": call.name,
- "arguments": call.arguments,
- })
-}
-
-fn tool_def_json(tool: &ToolDef) -> Value {
- json!({
- "name": tool.name,
- "description": tool.description,
- "parameters": tool.parameters,
- })
-}
-
fn token_usage_json(usage: &TokenUsage) -> Value {
json!({
"input": usage.input,
@@ -633,103 +122,6 @@ fn token_usage_json(usage: &TokenUsage) -> Value {
})
}
-fn format_messages(messages: &[ChatMessage]) -> String {
- if messages.is_empty() {
- return "(none)".to_owned();
- }
-
- messages
- .iter()
- .enumerate()
- .map(|(index, message)| {
- let mut text = format!("{}: {}", index + 1, message.role.as_str());
- if !message.content.is_empty() {
- text.push_str(&format!("\n{}", message.content));
- }
- if let Some(reasoning) = &message.reasoning_content {
- text.push_str(&format!("\n[reasoning]\n{reasoning}"));
- }
- for call in &message.tool_calls {
- text.push_str(&format!(
- "\n[tool_call {} {}]\n{}",
- call.id, call.name, call.arguments
- ));
- }
- if let Some(tool_call_id) = &message.tool_call_id {
- text.push_str(&format!("\n[tool_result for {tool_call_id}]"));
- }
- text
- })
- .collect::>()
- .join("\n\n")
-}
-
-#[derive(Default)]
-struct RoleCounts {
- user: usize,
- assistant: usize,
- tool: usize,
-}
-
-fn role_counts(messages: &[ChatMessage]) -> RoleCounts {
- let mut counts = RoleCounts::default();
- for message in messages {
- match message.role {
- Role::User => counts.user += 1,
- Role::Assistant => counts.assistant += 1,
- Role::Tool => counts.tool += 1,
- }
- }
- counts
-}
-
-fn message_text_bytes(messages: &[ChatMessage]) -> usize {
- messages
- .iter()
- .map(|message| {
- message.content.len()
- + message
- .reasoning_content
- .as_ref()
- .map(String::len)
- .unwrap_or(0)
- + message
- .tool_calls
- .iter()
- .map(|call| call.arguments.len())
- .sum::()
- })
- .sum()
-}
-
-fn entry(label: &str, value: &str) -> StackEntry {
- StackEntry {
- label: label.to_owned(),
- value: value.to_owned(),
- }
-}
-
-fn finish_reason_label(reason: &FinishReason) -> &str {
- match reason {
- FinishReason::Stop => "stop",
- FinishReason::ToolCalls => "tool_calls",
- FinishReason::Length => "length",
- FinishReason::Other(reason) => reason.as_str(),
- }
-}
-
-fn usage_summary(usage: Option<&TokenUsage>) -> String {
- match usage {
- Some(usage) => format!(
- "{} total tokens ({}, {})",
- usage.context_used(),
- token_source_label(usage.source),
- token_confidence_label(usage.confidence)
- ),
- None => "no token usage available".to_owned(),
- }
-}
-
fn token_source_label(source: TokenCountSource) -> &'static str {
match source {
TokenCountSource::ProviderReported => "provider-reported",
@@ -746,38 +138,19 @@ fn token_confidence_label(confidence: TokenCountConfidence) -> &'static str {
}
}
-fn provider_api_kind(trace: Option<&ProviderCallTrace>) -> &str {
- trace
- .map(|trace| trace.api_kind.as_str())
- .unwrap_or("model adapter")
-}
-
-fn present_label(value: Option<&str>) -> &'static str {
- match value {
- Some(value) if !value.is_empty() => "present",
- _ => "none",
- }
-}
-
-fn optional_label(value: Option<&str>) -> &str {
- value
- .filter(|value| !value.is_empty())
- .unwrap_or("(unavailable)")
-}
-
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn metadata_layer_keeps_provider_payloads_by_reference() {
+ fn build_captures_request_and_response_from_the_trace() {
let trace = ProviderCallTrace {
- api_kind: "openai-chat-completions".to_owned(),
- url: "https://api.example.test/chat/completions".to_owned(),
- model_id: "configured-model".to_owned(),
- request_payload: json!({ "messages": [{ "content": "request body" }] }),
- response_payload: Some(json!({ "choices": [{ "message": { "content": "reply" } }] })),
- provider_model_id: Some("provider-model".to_owned()),
+ api_kind: "codex-responses".to_owned(),
+ url: "https://chatgpt.com/backend-api/codex/responses".to_owned(),
+ model_id: "gpt-5.5".to_owned(),
+ request_payload: json!({ "input": [{ "role": "user", "content": "hi" }] }),
+ response_payload: Some(json!({ "output": [{ "type": "message" }] })),
+ provider_model_id: Some("gpt-5.5".to_owned()),
response_id: Some("resp_123".to_owned()),
request_id: Some("req_123".to_owned()),
status_code: Some(200),
@@ -790,38 +163,64 @@ mod tests {
status: "completed".to_owned(),
started_at_ms: 1,
duration_ms: 2.0,
- system_prompt: SystemPromptTrace {
- prompt: "system".to_owned(),
- selected_tools: Vec::new(),
- context_files: Vec::new(),
- cwd: "/tmp".to_owned(),
- date: "2026-06-01".to_owned(),
- },
- context_before: Vec::new(),
- tools: Vec::new(),
provider_trace: Some(trace),
- response: None,
token_usage: None,
- context_after: Vec::new(),
- steering_messages: Vec::new(),
error: None,
});
- let metadata = stack
- .layers
- .iter()
- .find(|layer| layer.kind == "metadata")
- .expect("metadata layer");
- let provider = metadata
- .json
- .as_ref()
- .and_then(|json| json.get("provider"))
- .expect("provider metadata");
+ assert_eq!(stack.request.api, "codex-responses");
+ assert_eq!(stack.request.model, "gpt-5.5");
+ assert_eq!(
+ stack.request.body.as_ref().unwrap()["input"][0]["role"],
+ "user"
+ );
+ assert_eq!(stack.response.status_code, Some(200));
+ assert_eq!(
+ stack.response.body.as_ref().unwrap()["output"][0]["type"],
+ "message"
+ );
+ assert_eq!(stack.response.error, None);
+ }
+
+ #[test]
+ fn build_carries_the_error_when_a_call_fails() {
+ let trace = ProviderCallTrace {
+ api_kind: "codex-responses".to_owned(),
+ url: "https://chatgpt.com/backend-api/codex/responses".to_owned(),
+ model_id: "gpt-5.5".to_owned(),
+ request_payload: json!({ "input": [] }),
+ response_payload: Some(json!({ "error": { "message": "bad input" } })),
+ provider_model_id: None,
+ response_id: None,
+ request_id: None,
+ status_code: Some(400),
+ error: Some("model request failed: http status: 400: bad input".to_owned()),
+ };
+
+ let stack = build_model_call_stack(ModelCallStackInput {
+ id: "call".to_owned(),
+ run_id: "run".to_owned(),
+ status: "failed".to_owned(),
+ started_at_ms: 1,
+ duration_ms: 2.0,
+ provider_trace: Some(trace),
+ token_usage: None,
+ error: Some("model request failed: http status: 400: bad input".to_owned()),
+ });
- assert!(provider.get("requestPayload").is_none());
- assert!(provider.get("responsePayload").is_none());
- assert_eq!(provider["responseId"], "resp_123");
- assert_eq!(provider["payloadLayers"]["request"], "provider_payload");
- assert_eq!(provider["payloadLayers"]["response"], "raw_response");
+ assert_eq!(stack.status, "failed");
+ assert_eq!(stack.response.status_code, Some(400));
+ assert_eq!(
+ stack.response.body.as_ref().unwrap()["error"]["message"],
+ "bad input"
+ );
+ assert!(
+ stack
+ .response
+ .error
+ .as_deref()
+ .unwrap()
+ .contains("bad input")
+ );
}
}
diff --git a/tests/local_backend.rs b/tests/local_backend.rs
index 5c8b963..6d2c275 100644
--- a/tests/local_backend.rs
+++ b/tests/local_backend.rs
@@ -536,18 +536,27 @@ fn session_stacks_rpc_returns_captured_model_calls() {
assert_eq!(stacks[0]["sequence"], 0);
assert_eq!(stacks[0]["status"], "completed");
- let layers = stacks[0]["layers"]
+ // The faithful record exposes what was sent and what came back.
+ let messages = stacks[0]["request"]["body"]["messages"]
.as_array()
- .expect("stack includes layers");
- assert!(
- layers.iter().any(|layer| layer["kind"] == "system_prompt"),
- "system prompt layer should be present: {response}"
+ .expect("request body carries the sent messages");
+ assert_eq!(
+ messages[0]["role"], "system",
+ "request should lead with the system prompt: {response}"
);
assert!(
- layers
+ messages
.iter()
- .any(|layer| layer["kind"] == "normalized_response"),
- "normalized response layer should be present: {response}"
+ .any(|message| message["content"] == "capture the stack"),
+ "request should carry the user message verbatim: {response}"
+ );
+ assert_eq!(
+ stacks[0]["response"]["statusCode"], 200,
+ "response status should be captured: {response}"
+ );
+ assert!(
+ !stacks[0]["response"]["body"].is_null(),
+ "a successful call should capture a response body: {response}"
);
let availability = json!({
diff --git a/tests/openai_model.rs b/tests/openai_model.rs
index 19deacc..ff0b495 100644
--- a/tests/openai_model.rs
+++ b/tests/openai_model.rs
@@ -729,6 +729,38 @@ fn a_provider_failure_is_reported_without_leaking_the_key() {
);
}
+#[test]
+fn a_provider_error_body_is_captured_in_the_trace() {
+ // The gpt-5.5 + tools failure mode: a 400 whose JSON body explains the
+ // rejection. The faithful record must keep that body, not just the status —
+ // that body is what makes the failure debuggable.
+ let (base_url, _requests) = fake_provider(
+ "400 Bad Request",
+ r#"{"error":{"message":"Invalid value for 'reasoning'","type":"invalid_request_error"}}"#,
+ );
+ let model = model(base_url);
+
+ let error = model
+ .respond_with_trace(&context(vec![ChatMessage::user("hi")]), &[])
+ .expect_err("a 400 response must surface as an error");
+
+ assert!(
+ error.message.contains("Invalid value for 'reasoning'"),
+ "the error detail should carry the provider's explanation: {}",
+ error.message
+ );
+ let trace = error
+ .provider_trace
+ .as_ref()
+ .expect("a failed call should still carry the provider trace");
+ assert_eq!(trace.status_code, Some(400));
+ let body = trace
+ .response_payload
+ .as_ref()
+ .expect("the provider error body should be captured");
+ assert_eq!(body["error"]["message"], "Invalid value for 'reasoning'");
+}
+
#[test]
fn a_malformed_response_is_reported() {
let (base_url, _requests) = fake_provider("200 OK", r#"{"unexpected":"shape"}"#);
diff --git a/tests/session.rs b/tests/session.rs
index 210db0d..d7b3eed 100644
--- a/tests/session.rs
+++ b/tests/session.rs
@@ -213,11 +213,11 @@ fn provider_token_usage_is_recorded_for_the_session() {
}
#[test]
-fn model_call_stacks_capture_context_response_and_carried_state() {
+fn model_call_stacks_capture_the_request_sent_and_the_response_received() {
let path =
std::env::temp_dir().join(format!("nav_session_stacks_{}.jsonl", uuid::Uuid::now_v7()));
let stack_store = Arc::new(StackStore::open(&path, 1024 * 1024).expect("open stack store"));
- let store = SessionStore::new(Arc::new(RecordingModel::new())).with_stack_store(stack_store);
+ let store = SessionStore::new(Arc::new(MockModel::new())).with_stack_store(stack_store);
let session_id = store.create_session();
store.send_message(&session_id, "show the stack").unwrap();
@@ -237,30 +237,25 @@ fn model_call_stacks_capture_context_response_and_carried_state() {
assert_eq!(stack.status, "completed");
assert_eq!(stack.run_id.len(), 36);
- let layer = |kind: &str| {
- stack
- .layers
- .iter()
- .find(|layer| layer.kind == kind)
- .unwrap_or_else(|| panic!("missing stack layer {kind}"))
- };
- assert_eq!(layer("system_prompt").status, "available");
- assert!(
- layer("session_history").summary.contains("1 message(s)"),
- "history layer should describe the request context: {:?}",
- layer("session_history")
- );
- assert_eq!(layer("provider_payload").status, "unavailable");
+ // The request body holds exactly what was sent: the system prompt leads, the
+ // user's message follows.
+ let request_body = stack.request.body.as_ref().expect("a captured request");
+ let messages = request_body["messages"].as_array().expect("messages array");
+ assert_eq!(messages[0]["role"], "system");
assert!(
- layer("normalized_response").summary.contains("stop finish"),
- "normalized layer should include finish reason: {:?}",
- layer("normalized_response")
+ messages
+ .iter()
+ .any(|message| message["content"] == "show the stack"),
+ "request should carry the user message verbatim: {messages:?}"
);
+
+ // The response body holds what came back, with no captured error.
+ assert_eq!(stack.response.status_code, Some(200));
assert!(
- layer("carried_forward").summary.contains("2 message(s)"),
- "carried-forward layer should include user plus assistant state: {:?}",
- layer("carried_forward")
+ stack.response.body.is_some(),
+ "a successful call should capture a response body"
);
+ assert_eq!(stack.response.error, None);
let _ = std::fs::remove_file(path);
}
diff --git a/tests/stack_store.rs b/tests/stack_store.rs
index d06f4f3..14ea94b 100644
--- a/tests/stack_store.rs
+++ b/tests/stack_store.rs
@@ -1,6 +1,7 @@
use std::fs;
-use nav::{ModelCallStack, StackLayer, StackStore};
+use nav::{ModelCallRequest, ModelCallResponse, ModelCallStack, StackStore};
+use serde_json::json;
fn stack(id: &str, sequence: u64) -> ModelCallStack {
ModelCallStack {
@@ -10,15 +11,18 @@ fn stack(id: &str, sequence: u64) -> ModelCallStack {
status: "completed".to_owned(),
started_at_ms: sequence,
duration_ms: 1.0,
- layers: vec![StackLayer {
- kind: "metadata".to_owned(),
- title: "Metadata".to_owned(),
- status: "available".to_owned(),
- summary: format!("stack {id}"),
- entries: Vec::new(),
- text: None,
- json: None,
- }],
+ request: ModelCallRequest {
+ api: "mock".to_owned(),
+ url: "mock://local".to_owned(),
+ model: "mock".to_owned(),
+ body: Some(json!({ "messages": [{ "role": "user", "content": id }] })),
+ },
+ response: ModelCallResponse {
+ status_code: Some(200),
+ body: Some(json!({ "output": [{ "content": format!("reply {id}") }] })),
+ error: None,
+ token_usage: None,
+ },
}
}