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, + }, } }