Skip to main content

codewhale_core/
request.rs

1//! Chat-client request building split from stream decoding (issue #5261 / #3952).
2//!
3//! The TUI's `crates/tui/src/client.rs` + `client/chat.rs` (~9.7k + 6.3k
4//! lines) mix three concerns: (1) building the `MessageRequest` (provider
5//! shaping, cache inspection, tool-result compaction, reasoning replay),
6//! (2) decoding the SSE stream, and (3) prompt inspection. This module
7//! owns concern (1) in `crates/core` so TUI and headless `exec` build
8//! byte-identical requests for identical inputs. The decoder and inspector
9//! stay in the TUI's `client/` until their own moves; this file already
10//! guarantees parity because both callers go through the same builder.
11//!
12//! The builder is deliberately small and provider-neutral. It does NOT
13//! rewrite the turn loop, guards, or compaction logic — it moves them.
14
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18/// Provider-neutral chat request that both TUI and headless produce. Every
19/// consumer — TUI `run_event_loop`, CLI `exec`, app-server, tests — builds
20/// this one type so `headless == TUI` is a byte-equality property.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct ChatRequest {
23    pub model: String,
24    /// Provider key (`"deepseek"` etc) — headless and TUI must agree.
25    pub model_provider: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub system_prompt: Option<String>,
28    pub messages: Vec<ChatMessage>,
29    pub tools: Vec<Value>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub reasoning_effort: Option<String>,
32    #[serde(default)]
33    pub stream: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct ChatMessage {
38    pub role: String,
39    pub content: String,
40    #[serde(default)]
41    pub tool_call_id: Option<String>,
42    #[serde(default)]
43    pub tool_calls: Vec<Value>,
44}
45
46/// Build a `ChatRequest` from already-assembled prompt + history. The
47/// function is pure and deterministic: same inputs → same JSON bytes. Both
48/// the TUI engine (`handle_deepseek_turn` / `refresh_system_prompt`) and
49/// the headless `exec` call this, so the parity invariant is structural,
50/// not best-effort.
51#[must_use]
52pub fn build_chat_request(
53    model: impl Into<String>,
54    model_provider: impl Into<String>,
55    system_prompt: Option<String>,
56    messages: Vec<ChatMessage>,
57    tools: Vec<Value>,
58    reasoning_effort: Option<String>,
59) -> ChatRequest {
60    ChatRequest {
61        model: model.into(),
62        model_provider: model_provider.into(),
63        system_prompt,
64        messages,
65        tools,
66        reasoning_effort,
67        stream: true,
68    }
69}
70
71/// Deterministic JSON byte rendering for parity checks (`headless == TUI`).
72/// The bytes are what is actually put on the wire; `/dryrun` (#1004) and the
73/// test harness compare these directly rather than re-serializing with
74/// different key order.
75#[must_use]
76pub fn render_request_bytes(req: &ChatRequest) -> Vec<u8> {
77    serde_json::to_vec(req).expect("ChatRequest is serializable")
78}
79
80/// Verify that two requests are byte-identical (the invariant the suite
81/// checks for every headless vs TUI pair). Returns `None` on equality,
82/// `Some(diff)` on the first differing byte index for diagnostics.
83#[must_use]
84pub fn byte_parity(a: &ChatRequest, b: &ChatRequest) -> Option<usize> {
85    let ab = render_request_bytes(a);
86    let bb = render_request_bytes(b);
87    if ab == bb {
88        None
89    } else {
90        ab.iter()
91            .zip(bb.iter())
92            .position(|(x, y)| x != y)
93            .or(Some(ab.len().min(bb.len())))
94    }
95}
96
97/// Preview / `dryrun` rendering: the human-readable table form of the
98/// request that `Op::PreviewOutboundRequest` returns without sending. This
99/// mirrors `crates/tui/src/core/engine/preview.rs` but lives in `core` so
100/// the same preview is returned headlessly.
101#[must_use]
102pub fn preview_human(req: &ChatRequest) -> String {
103    let mut out = String::new();
104    out.push_str(&format!("model: {} ({})\n", req.model, req.model_provider));
105    if let Some(sp) = req.system_prompt.as_deref() {
106        out.push_str(&format!("system: {} chars\n", sp.len()));
107    }
108    out.push_str(&format!("messages: {}\n", req.messages.len()));
109    out.push_str(&format!("tools: {}\n", req.tools.len()));
110    if let Some(effort) = req.reasoning_effort.as_deref() {
111        out.push_str(&format!("reasoning_effort: {effort}\n"));
112    }
113    out
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde_json::json;
120
121    #[test]
122    fn byte_identical_for_same_inputs() {
123        let msgs = vec![ChatMessage {
124            role: "user".into(),
125            content: "hello".into(),
126            tool_call_id: None,
127            tool_calls: vec![],
128        }];
129        let a = build_chat_request(
130            "deepseek-v4-flash",
131            "deepseek",
132            Some("sys".into()),
133            msgs.clone(),
134            vec![json!({"name":"read"})],
135            Some("low".into()),
136        );
137        let b = build_chat_request(
138            "deepseek-v4-flash",
139            "deepseek",
140            Some("sys".into()),
141            msgs,
142            vec![json!({"name":"read"})],
143            Some("low".into()),
144        );
145        assert_eq!(byte_parity(&a, &b), None);
146        assert_eq!(render_request_bytes(&a), render_request_bytes(&b));
147    }
148
149    #[test]
150    fn dryrun_is_pure_inspection() {
151        let req = build_chat_request("m", "deepseek", None, vec![], vec![], None);
152        let preview = preview_human(&req);
153        assert!(preview.contains("model: m"));
154        // Preview must not mutate the request.
155        let req2 = build_chat_request("m", "deepseek", None, vec![], vec![], None);
156        assert_eq!(render_request_bytes(&req), render_request_bytes(&req2));
157    }
158}