{"record":{"id":"d0c38cb985115370","repo":"zeroclaw-labs/zeroclaw","slug":"refusing-to-dispatch-to-provider-prepared-history","errorCode":null,"errorMessage":"refusing to dispatch to provider: prepared history has no user turn (system-only after leading-turn-order sanitize)","messagePattern":"refusing to dispatch to provider: prepared history has no user turn \\(system-only after leading-turn-order sanitize\\)","errorType":"exception","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/agent/turn/vision_route.rs","lineNumber":130,"sourceCode":"        None\n    };\n\n    Ok((vision_model_provider, degrade_strip_images))\n}\n\npub(crate) async fn prepare_messages_for_iteration(\n    history: &[ChatMessage],\n    multimodal_config: &MultimodalConfig,\n    degrade_strip_images: bool,\n    image_cache: Option<&mut multimodal::LocalImageCache>,\n) -> Result<multimodal::PreparedMessages> {\n    // Enforce the universal leading-turn-order invariant before any provider\n    // sees the history: strict providers reject a first non-system turn that is\n    // not `user`, which context trims and session restores can produce.\n    let mut sanitized = history.to_vec();\n    ChatMessage::sanitize_leading_turn_order(&mut sanitized);\n    if !sanitized.iter().any(ChatMessage::is_user) {\n        anyhow::bail!(\n            \"refusing to dispatch to provider: prepared history has no user turn \\\n             (system-only after leading-turn-order sanitize)\"\n        );\n    }\n    let history = sanitized.as_slice();\n    if degrade_strip_images {\n        // Text-only fallback: replace every media marker with a\n        // `[media attachment]` placeholder so no filesystem path or data\n        // URI reaches the text-only provider, while surrounding text\n        // (captions, tool metadata) survives.\n        let stripped: Vec<ChatMessage> = history\n            .iter()\n            .map(|m| ChatMessage {\n                role: m.role.clone(),\n                content: multimodal::strip_media_markers(&m.content),\n            })\n            .collect();\n        match image_cache {","sourceCodeStart":112,"sourceCodeEnd":148,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/agent/turn/vision_route.rs#L112-L148","documentation":"prepare_messages_for_iteration enforces the universal leading-turn-order invariant before any provider dispatch: strict providers reject a first non-system turn that is not user, so leading assistant/tool-call turns are sanitized away. If after sanitization no user message remains at all (system-only history), the runtime fails closed — it refuses the provider call rather than sending a request the provider will reject opaquely.","triggerScenarios":"Context trimming drops the last remaining user turn; a restored session contains only system + assistant/tool messages; histories built upstream without any user role. Tests prepare_fails_closed_when_no_user_turn_survives and prepare_strips_leading_assistant_tool_call pin exactly these shapes.","commonSituations":"Aggressive context-window trimming on long sessions; hand-built or migrated histories missing user turns; session restore after crashes; summarization pipelines that replace the user turn with a system summary and trim the original.","solutions":["Replay the prepared history and confirm at least one ChatMessage::is_user survives trimming","Adjust the trim policy to always retain the original (or a synthetic) user turn","When restoring sessions, re-seed a user message summarizing the request before resuming the loop","Fix upstream builders that construct system-only histories"],"exampleFix":"// before: trim policy can drop the only user turn\nhistory.retain(|m| !m.is_old(cutoff));\n\n// after: always keep the initial user turn\nhistory.retain(|m| !m.is_old(cutoff) || m.is_initial_user_turn());","handlingStrategy":"validation","validationCode":"let mut history = build_history(session);\nif !history.iter().any(ChatMessage::is_user) {\n    history.push(ChatMessage::user(session.request_summary())); // re-seed a user turn\n}\nlet resp = agent.run_tool_call_loop(history, ...).await?;","typeGuard":"fn history_has_user_turn(history: &[ChatMessage]) -> bool {\n    history.iter().any(ChatMessage::is_user)\n}","tryCatchPattern":"match agent.run_turn(req).await {\n    Err(ref e) if e.to_string().contains(\"no user turn\") => {\n        // history is structurally invalid: re-seed a user turn and rebuild the session\n    }\n    other => other,\n}","preventionTips":["Configure trim policies to always retain the initial user turn","Validate restored sessions before resuming: at least one user message must survive","Never synthesize system-only histories for provider dispatch","Test trimming with adversarial long sessions"],"tags":["chat-history","turn-order","context-trimming","fail-closed","provider-validation"],"backgroundTag":"malformed-chat-history","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}