Skip to main content

embacle_server/uhp/
tasks.rs

1// ABOUTME: POST /v1/responses and the stored Response objects it produces, plus read-back
2// ABOUTME: Reserved fields are ignored observably; usage is reported honestly or as null
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Running a task.
8//!
9//! `POST /v1/responses` is the core of the protocol: one unit of work, run on a
10//! named harness, returned as a `Response` object. The surface is shaped like a
11//! responses API rather than a chat-completions one, which is why it lives here
12//! and not in the `OpenAI` handler beside it.
13//!
14//! Three requirements shape this module more than the happy path does.
15//!
16//! **Reserved fields are ignored, not rejected.** `tools` and `include` are
17//! reserved by the specification, and a client that sends them for wire
18//! compatibility with another API must still be served. But ignoring has to be
19//! *observable*: dropping a field silently is indistinguishable from acting on
20//! it, so what was dropped comes back in `metadata.ignored_fields` — and only
21//! what this request actually carried, never a catalogue of what the server
22//! would drop.
23//!
24//! **Usage is honest or absent.** `null` when the runner accounted for nothing.
25//! A fabricated zero is worse than an honest absence, because a client cannot
26//! tell it from a free task.
27//!
28//! **A terminal state never changes.** Reading a task back must return the same
29//! status it finished with.
30
31use std::collections::{HashMap, HashSet};
32use std::env;
33use std::fs;
34use std::path::Path as StdPath;
35use std::sync::{Arc, RwLock};
36use std::time::{SystemTime, UNIX_EPOCH};
37
38use axum::extract::{Path, State};
39use axum::response::{IntoResponse, Response as AxumResponse};
40use axum::Json;
41use embacle::config::{CliRunnerType, RunnerConfig};
42use embacle::discovery::resolve_binary;
43use embacle::factory::create_runner_with_config;
44use embacle::types::{ChatMessage, ChatRequest, LlmProvider, RunnerError};
45use serde::{Deserialize, Serialize};
46use serde_json::{Map, Value};
47use uuid::fmt::Simple;
48use uuid::Uuid;
49
50use super::error::{ErrorType, UhpFailure};
51use super::files;
52use super::harnesses;
53use super::state::UhpState;
54
55/// The fields this protocol reserves and a server must ignore.
56const RESERVED: [&str; 2] = ["tools", "include"];
57
58/// What a client sends to run a task.
59#[derive(Debug, Clone, Deserialize)]
60pub struct CreateResponse {
61    /// A bare string is shorthand for one user message.
62    pub input: Value,
63    /// Canonical model id; absent means the harness default.
64    #[serde(default)]
65    pub model: Option<String>,
66    /// Client metadata. `harness_id` selects the harness.
67    #[serde(default)]
68    pub metadata: Option<Map<String, Value>>,
69    /// Whether to stream the result as server-sent events.
70    #[serde(default)]
71    pub stream: bool,
72    /// Return immediately with an `in_progress` task and keep running it.
73    #[serde(default)]
74    pub background: bool,
75    /// Continue the session this response belongs to.
76    #[serde(default)]
77    pub previous_response_id: Option<String>,
78    /// System instructions for the turn.
79    #[serde(default)]
80    pub instructions: Option<String>,
81    /// Everything the client sent that this struct does not name, kept so
82    /// reserved fields can be reported rather than silently dropped.
83    #[serde(flatten)]
84    pub rest: Map<String, Value>,
85}
86
87impl CreateResponse {
88    /// The prompt text, whether the client sent a string or an item array.
89    pub fn prompt(&self) -> String {
90        match &self.input {
91            Value::String(s) => s.clone(),
92            Value::Array(items) => items
93                .iter()
94                .filter_map(|item| {
95                    item.get("content").map(|c| match c {
96                        Value::String(s) => s.clone(),
97                        other => other
98                            .as_array()
99                            .map(|parts| {
100                                parts
101                                    .iter()
102                                    .filter_map(|p| p.get("text").and_then(Value::as_str))
103                                    .collect::<Vec<_>>()
104                                    .join("")
105                            })
106                            .unwrap_or_default(),
107                    })
108                })
109                .collect::<Vec<_>>()
110                .join("\n"),
111            other => other.as_str().unwrap_or_default().to_owned(),
112        }
113    }
114
115    /// Which reserved fields this request actually carried, in spec order.
116    ///
117    /// Reports what was sent and dropped — not what the server would drop, and
118    /// not what it happens to know about. A hardcoded list tells a client its
119    /// request was altered when it was not.
120    pub fn ignored_fields(&self) -> Vec<String> {
121        RESERVED
122            .iter()
123            .filter(|f| self.rest.contains_key(**f))
124            .map(|f| (*f).to_owned())
125            .collect()
126    }
127
128    /// The harness the client asked for, if any.
129    pub fn harness_id(&self) -> Option<&str> {
130        self.metadata
131            .as_ref()?
132            .get("harness_id")
133            .and_then(Value::as_str)
134    }
135}
136
137/// One item of a task's output.
138#[derive(Debug, Clone, Serialize)]
139pub struct OutputItem {
140    /// Item kind; `message` is the only one this server produces today.
141    #[serde(rename = "type")]
142    pub kind: &'static str,
143    /// Item id.
144    pub id: String,
145    /// Terminal status of the item.
146    pub status: &'static str,
147    /// Who produced it.
148    pub role: &'static str,
149    /// The parts of the message.
150    pub content: Vec<ContentPart>,
151}
152
153/// One part of a message.
154#[derive(Debug, Clone, Serialize)]
155pub struct ContentPart {
156    /// Part kind.
157    #[serde(rename = "type")]
158    pub kind: &'static str,
159    /// The text itself.
160    pub text: String,
161}
162
163/// Token accounting, when the runner reported any.
164#[derive(Debug, Clone, Copy, Serialize)]
165pub struct Usage {
166    /// Tokens in the prompt.
167    pub input_tokens: u32,
168    /// Tokens generated.
169    pub output_tokens: u32,
170    /// Their sum.
171    pub total_tokens: u32,
172}
173
174/// A task, in whatever state it reached.
175#[derive(Debug, Clone, Serialize)]
176pub struct Response {
177    /// `resp_`-prefixed id.
178    pub id: String,
179    /// Always `response`.
180    pub object: &'static str,
181    /// Unix seconds.
182    pub created_at: u64,
183    /// One of the five lifecycle states.
184    pub status: &'static str,
185    /// Non-null only when `status` is `failed`.
186    pub error: Option<super::error::UhpError>,
187    /// The response this one continued, if any.
188    pub previous_response_id: Option<String>,
189    /// The model that actually ran.
190    pub model: String,
191    /// What the task produced.
192    pub output: Vec<OutputItem>,
193    /// Whether the server retained it.
194    pub store: bool,
195    /// `null` when the runner accounted for nothing.
196    pub usage: Option<Usage>,
197    /// Session id, ignored fields, and any model substitution.
198    pub metadata: Map<String, Value>,
199}
200
201/// Responses this server has run and retained, and the sessions they belong to.
202///
203/// LIMITATION(registre#410): `ResponseStore` is per-process, so a task run on one
204/// instance cannot be read back from another.
205#[derive(Debug, Default)]
206pub struct ResponseStore {
207    responses: RwLock<HashMap<String, Response>>,
208    sessions: RwLock<HashMap<String, Vec<String>>>,
209    cancelled: RwLock<HashSet<String>>,
210}
211
212impl ResponseStore {
213    /// Retain a finished response and record it against its session.
214    pub fn put(&self, session_id: &str, response: Response) {
215        if let Ok(mut sessions) = self.sessions.write() {
216            sessions
217                .entry(session_id.to_owned())
218                .or_default()
219                .push(response.id.clone());
220        }
221        if let Ok(mut responses) = self.responses.write() {
222            responses.insert(response.id.clone(), response);
223        }
224    }
225
226    /// Read a response back by id.
227    pub fn get(&self, id: &str) -> Option<Response> {
228        self.responses.read().ok()?.get(id).cloned()
229    }
230
231    /// Every session this server holds.
232    pub fn session_ids(&self) -> Vec<String> {
233        self.sessions
234            .read()
235            .map(|s| s.keys().cloned().collect())
236            .unwrap_or_default()
237    }
238
239    /// The responses making up one session, oldest first.
240    pub fn session_turns(&self, session_id: &str) -> Option<Vec<Response>> {
241        let ids = self.sessions.read().ok()?.get(session_id)?.clone();
242        let responses = self.responses.read().ok()?;
243        Some(
244            ids.iter()
245                .filter_map(|i| responses.get(i).cloned())
246                .collect(),
247        )
248    }
249
250    /// Ask a running task to stop.
251    ///
252    /// Returns whether the task was still running. A terminal task is left
253    /// exactly as it is: a client retrying a cancel after a dropped connection
254    /// must not be punished for having succeeded the first time, and a
255    /// terminal status must never change afterwards.
256    pub fn cancel(&self, id: &str) -> bool {
257        let running = self.get(id).is_some_and(|r| r.status == "in_progress");
258        if running {
259            if let Ok(mut cancelled) = self.cancelled.write() {
260                cancelled.insert(id.to_owned());
261            }
262            if let Ok(mut responses) = self.responses.write() {
263                if let Some(r) = responses.get_mut(id) {
264                    r.status = "cancelled";
265                }
266            }
267        }
268        running
269    }
270
271    /// Whether this task was asked to stop.
272    pub fn is_cancelled(&self, id: &str) -> bool {
273        self.cancelled.read().is_ok_and(|c| c.contains(id))
274    }
275
276    /// Replace a stored response, unless a cancel already settled it.
277    ///
278    /// Cancellation wins: a task that finished after the client asked it to
279    /// stop must not report `completed`, or the cancel silently did nothing.
280    pub fn settle(&self, response: Response) {
281        if self.is_cancelled(&response.id) {
282            return;
283        }
284        if let Ok(mut responses) = self.responses.write() {
285            responses.insert(response.id.clone(), response);
286        }
287    }
288
289    /// The ids of a session's responses that are still running.
290    pub fn running_in(&self, session_id: &str) -> Vec<String> {
291        self.session_turns(session_id)
292            .unwrap_or_default()
293            .into_iter()
294            .filter(|r| r.status == "in_progress")
295            .map(|r| r.id)
296            .collect()
297    }
298
299    /// Forget a session and every response in it.
300    ///
301    /// Returns whether it existed, so the caller can answer `session_not_found`
302    /// rather than reporting a delete that removed nothing.
303    pub fn delete_session(&self, session_id: &str) -> bool {
304        let ids = self
305            .sessions
306            .write()
307            .map_or(None, |mut sessions| sessions.remove(session_id));
308        let Some(ids) = ids else { return false };
309        if let Ok(mut responses) = self.responses.write() {
310            for id in &ids {
311                responses.remove(id);
312            }
313        }
314        true
315    }
316
317    /// The session a response belongs to.
318    pub fn session_of(&self, response_id: &str) -> Option<String> {
319        self.get(response_id)?
320            .metadata
321            .get("session_id")
322            .and_then(Value::as_str)
323            .map(str::to_owned)
324    }
325}
326
327/// Unix seconds now.
328fn now_secs() -> u64 {
329    SystemTime::now()
330        .duration_since(UNIX_EPOCH)
331        .map(|d| d.as_secs())
332        .unwrap_or_default()
333}
334
335/// The prefix of every session id this server mints.
336const SESSION_PREFIX: &str = "sess_";
337
338/// A prefixed id, as the object model requires.
339fn id_with(prefix: &str) -> String {
340    format!("{prefix}{}", Uuid::new_v4().simple())
341}
342
343/// Whether `id` has exactly the shape [`id_with`] mints for a session: the
344/// prefix, then a UUID in its simple form, 32 lowercase hex digits.
345///
346/// A session id doubles as its container id and names a folder on disk, so an
347/// id a request carries is held to this shape before it gets near a path, and
348/// no id carrying a separator, a `..` or an absolute path passes it.
349#[must_use]
350pub fn is_session_id(id: &str) -> bool {
351    id.strip_prefix(SESSION_PREFIX).is_some_and(|hex| {
352        hex.len() == Simple::LENGTH
353            && hex
354                .bytes()
355                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
356    })
357}
358
359/// Handle `POST /v1/responses`.
360///
361/// # Errors
362///
363/// Answers `404 harness_not_found` for an unknown harness and
364/// `400 invalid_input` when the request names no runnable harness.
365pub async fn create(
366    State(state): State<UhpState>,
367    Json(request): Json<CreateResponse>,
368) -> Result<AxumResponse, UhpFailure> {
369    if request.background {
370        return Ok(Json(start_background(state, request)).into_response());
371    }
372    if request.stream {
373        let prepared = prepare(&state, &request).await?;
374        let previous = request.previous_response_id.clone();
375        return Ok(super::streaming::sse(state, prepared, previous).into_response());
376    }
377    let outcome = run(&state, &request).await?;
378    Ok(Json(outcome.response).into_response())
379}
380
381/// Start a task, retain it as `in_progress`, and answer straight away.
382///
383/// Background is what makes cancellation observable: a task the client is
384/// still waiting on cannot be cancelled by that same client. The response is
385/// stored before this returns, so the id it hands back is immediately readable.
386///
387/// The harness is resolved on the spawned task, so a resolution failure
388/// surfaces as a `failed` response rather than a refusal to start — the client
389/// already holds the id by then, and an id that never resolves is worse than
390/// one that resolves to a failure.
391fn start_background(state: UhpState, request: CreateResponse) -> Response {
392    let id = id_with("resp_");
393    let session_id = request
394        .previous_response_id
395        .as_ref()
396        .and_then(|prev| state.store.session_of(prev))
397        .unwrap_or_else(|| id_with(SESSION_PREFIX));
398
399    let mut metadata = request.metadata.clone().unwrap_or_default();
400    metadata.insert("session_id".to_owned(), Value::String(session_id.clone()));
401
402    let pending = Response {
403        id: id.clone(),
404        object: "response",
405        created_at: now_secs(),
406        status: "in_progress",
407        error: None,
408        previous_response_id: request.previous_response_id.clone(),
409        model: request.model.clone().unwrap_or_default(),
410        output: Vec::new(),
411        store: true,
412        usage: None,
413        metadata,
414    };
415    state.store.put(&session_id, pending.clone());
416
417    let spawned = pending.clone();
418    tokio::spawn(async move {
419        let finished = match run(&state, &request).await {
420            Ok(outcome) => {
421                // The background task keeps the id the client already holds;
422                // the run allocated its own, which nobody has seen.
423                let mut r = outcome.response;
424                r.id = id;
425                r
426            }
427            Err(failure) => {
428                let mut r = spawned;
429                r.status = "failed";
430                r.error = Some(failure.into_error());
431                r
432            }
433        };
434        state.store.settle(finished);
435    });
436
437    pending
438}
439
440/// Handle `POST /v1/responses/{response_id}/cancel`.
441///
442/// # Errors
443///
444/// Answers `404 response_not_found` when this server holds no such response.
445pub async fn cancel(
446    State(state): State<UhpState>,
447    Path(id): Path<String>,
448) -> Result<Json<Response>, UhpFailure> {
449    if state.store.get(&id).is_none() {
450        return Err(UhpFailure::not_found(
451            "response_not_found",
452            "no response with that id exists",
453        ));
454    }
455    state.store.cancel(&id);
456    state.store.get(&id).map(Json).ok_or_else(|| {
457        UhpFailure::not_found("response_not_found", "no response with that id exists")
458    })
459}
460
461/// A finished task plus what the stream needs to describe it.
462pub struct Outcome {
463    /// The response object itself.
464    pub response: Response,
465}
466
467/// Everything a task needs once the harness and session are settled.
468pub struct Prepared {
469    /// The runner that will answer.
470    pub runner: Arc<dyn LlmProvider>,
471    /// The request to hand it.
472    pub chat: ChatRequest,
473    /// The model that will run.
474    pub model: String,
475    /// The session this task belongs to.
476    pub session_id: String,
477    /// The response id, allocated before the work starts.
478    pub id: String,
479    /// Session id, ignored fields, and any model substitution.
480    pub metadata: Map<String, Value>,
481    /// What the client asked for, when it differs from what will run.
482    pub requested_model: Option<String>,
483}
484
485/// Build a runner that executes inside `workdir`.
486///
487/// Deliberately not the shared pooled runner: pooling hands back one runner per
488/// provider for the whole process, and every session needs its own directory or
489/// their artifacts land in one pile with no way to tell them apart.
490async fn runner_in(
491    provider: CliRunnerType,
492    workdir: &StdPath,
493) -> Result<Arc<dyn LlmProvider>, RunnerError> {
494    fs::create_dir_all(workdir)
495        .map_err(|e| RunnerError::internal(format!("session working folder: {e}")))?;
496
497    let env_override = env::var(provider.env_override_key()).ok();
498    let binary = resolve_binary(provider.binary_name(), env_override.as_deref())?;
499    let mut config = RunnerConfig::new(binary).with_working_directory(workdir.to_path_buf());
500    config.extra_args = write_permission_args(provider);
501
502    Ok(Arc::from(
503        create_runner_with_config(provider, config).await?,
504    ))
505}
506
507/// The arguments that let a harness write inside its own working folder.
508///
509/// A UHP server exists to run agent harnesses, and a harness that cannot write
510/// a file cannot produce an artifact — the whole files half of the protocol is
511/// unreachable without this. What makes it safe to grant is the confinement,
512/// not trust: every task runs with its working directory set to that session's
513/// folder alone, under embacle's sandbox policy, so an accepted edit lands
514/// there and nowhere else.
515///
516/// Both flags are needed and neither is sufficient. `--permission-mode
517/// acceptEdits` stops the interactive prompt, but with no MCP servers
518/// configured the Write tool is still off the allow-list, so creating a file is
519/// refused — and the refusal surfaces as prose from the model rather than an
520/// error, which reads exactly like a model choosing not to act. Verified
521/// against the CLI directly: with only `--permission-mode`, the run's
522/// `permission_denials` carries the Write call.
523///
524/// Empty for a harness with no such flag: it either already writes freely or it
525/// does not write at all, and inventing a flag name would fail at run time with
526/// an unparseable argument rather than a missing file.
527fn write_permission_args(provider: CliRunnerType) -> Vec<String> {
528    match provider {
529        CliRunnerType::ClaudeCode => vec![
530            "--permission-mode".to_owned(),
531            "acceptEdits".to_owned(),
532            "--allowed-tools".to_owned(),
533            "Write Edit".to_owned(),
534        ],
535        _ => Vec::new(),
536    }
537}
538
539/// Resolve the harness, model and session for a task without running it.
540///
541/// Shared by the blocking and streaming paths so the two cannot drift on which
542/// harness they picked or which session they reported.
543///
544/// # Errors
545///
546/// Answers `404 harness_not_found` when the requested harness is not installed.
547pub async fn prepare(state: &UhpState, request: &CreateResponse) -> Result<Prepared, UhpFailure> {
548    let harnesses = harnesses::discover(&state.shared).await;
549    let chosen = match request.harness_id() {
550        Some(id) => harnesses
551            .iter()
552            .find(|d| d.harness.id == id)
553            .ok_or_else(|| {
554                UhpFailure::not_found("harness_not_found", "no harness with that id is configured")
555            })?,
556        None => harnesses.first().ok_or_else(|| {
557            UhpFailure::new(
558                ErrorType::InvalidRequestError,
559                "harness_not_found",
560                "this server has no installed harness to run",
561            )
562        })?,
563    };
564
565    // The harness runs inside the session's own folder, which is what makes an
566    // artifact attributable: a file that appears there was written by this
567    // session's tasks and nothing else. Without it the harness writes into
568    // whatever directory the server happened to start in, and the files it
569    // produces belong to no one.
570    let session_id = request
571        .previous_response_id
572        .as_ref()
573        .and_then(|prev| state.store.session_of(prev))
574        .unwrap_or_else(|| id_with(SESSION_PREFIX));
575    let workdir = files::session_workdir(&session_id);
576    let runner = runner_in(chosen.provider, &workdir).await.map_err(|_| {
577        UhpFailure::new(
578            ErrorType::HarnessError,
579            "harness_unavailable",
580            "the harness could not be started",
581        )
582    })?;
583
584    let requested_model = request.model.clone();
585    let model = requested_model
586        .clone()
587        .or_else(|| chosen.harness.default_model.clone())
588        .unwrap_or_else(|| runner.default_model().to_owned());
589
590    let mut chat = ChatRequest::new(vec![ChatMessage::user(request.prompt())]);
591    chat.model = Some(model.clone());
592
593    let mut metadata = request.metadata.clone().unwrap_or_default();
594    metadata.insert("session_id".to_owned(), Value::String(session_id.clone()));
595    let ignored = request.ignored_fields();
596    if !ignored.is_empty() {
597        metadata.insert(
598            "ignored_fields".to_owned(),
599            Value::Array(ignored.into_iter().map(Value::String).collect()),
600        );
601    }
602
603    Ok(Prepared {
604        runner,
605        chat,
606        model,
607        session_id,
608        id: id_with("resp_"),
609        metadata,
610        requested_model,
611    })
612}
613
614/// The response object for a task that has not produced anything yet.
615#[must_use]
616pub fn pending_response(p: &Prepared, previous: Option<String>) -> Response {
617    Response {
618        id: p.id.clone(),
619        object: "response",
620        created_at: now_secs(),
621        status: "in_progress",
622        error: None,
623        previous_response_id: previous,
624        model: p.model.clone(),
625        output: Vec::new(),
626        store: true,
627        usage: None,
628        metadata: p.metadata.clone(),
629    }
630}
631
632/// Wrap finished text as the terminal response object.
633#[must_use]
634pub fn completed_response(
635    p: Prepared,
636    previous: Option<String>,
637    text: String,
638    usage: Option<Usage>,
639    ran_model: String,
640) -> Response {
641    let mut metadata = p.metadata;
642    if p.requested_model.as_ref().is_some_and(|m| *m != ran_model) {
643        metadata.insert(
644            "requested_model".to_owned(),
645            Value::String(p.requested_model.unwrap_or_default()),
646        );
647        metadata.insert("model_fallback".to_owned(), Value::Bool(true));
648    }
649    Response {
650        id: p.id,
651        object: "response",
652        created_at: now_secs(),
653        status: "completed",
654        error: None,
655        previous_response_id: previous,
656        model: ran_model,
657        output: vec![OutputItem {
658            kind: "message",
659            id: id_with("msg_"),
660            status: "completed",
661            role: "assistant",
662            content: vec![ContentPart {
663                kind: "output_text",
664                text,
665            }],
666        }],
667        store: true,
668        usage,
669        metadata,
670    }
671}
672
673/// Run one task to a terminal state.
674///
675/// # Errors
676///
677/// Answers `404 harness_not_found` when the requested harness is not installed.
678pub async fn run(state: &UhpState, request: &CreateResponse) -> Result<Outcome, UhpFailure> {
679    let prepared = prepare(state, request).await?;
680    let session_id = prepared.session_id.clone();
681    let previous = request.previous_response_id.clone();
682    let model = prepared.model.clone();
683
684    let response = match prepared.runner.complete(&prepared.chat).await {
685        Ok(reply) => {
686            let ran = if reply.model.is_empty() {
687                model
688            } else {
689                reply.model
690            };
691            // Honest or absent: a fabricated zero cannot be told from a free
692            // task, which the specification calls the worse outcome.
693            let usage = reply.usage.map(|u| Usage {
694                input_tokens: u.prompt_tokens,
695                output_tokens: u.completion_tokens,
696                total_tokens: u.total_tokens,
697            });
698            completed_response(prepared, previous, reply.content, usage, ran)
699        }
700        Err(e) => {
701            let mut failed = pending_response(&prepared, previous);
702            failed.status = "failed";
703            failed.error = Some(super::error::UhpError {
704                kind: ErrorType::HarnessError,
705                code: "harness_error".to_owned(),
706                message: e.message,
707                detail: None,
708            });
709            failed
710        }
711    };
712
713    state.store.put(&session_id, response.clone());
714    Ok(Outcome { response })
715}
716
717/// Handle `GET /v1/responses/{response_id}`.
718///
719/// # Errors
720///
721/// Answers `404 response_not_found` when this server holds no such response.
722pub async fn get(
723    State(state): State<UhpState>,
724    Path(id): Path<String>,
725) -> Result<Json<Response>, UhpFailure> {
726    state.store.get(&id).map(Json).ok_or_else(|| {
727        UhpFailure::not_found("response_not_found", "no response with that id exists")
728    })
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use serde_json::json;
735
736    fn parse(body: Value) -> CreateResponse {
737        serde_json::from_value(body).expect("request parses") // Safe: test assertion
738    }
739
740    #[test]
741    fn a_bare_string_input_is_one_user_message() {
742        assert_eq!(parse(json!({"input": "hello"})).prompt(), "hello");
743    }
744
745    #[test]
746    fn an_item_array_input_is_flattened_to_its_text() {
747        let r = parse(json!({"input": [
748            {"role": "user", "content": [{"type": "input_text", "text": "first"}]},
749            {"role": "user", "content": "second"}
750        ]}));
751        assert_eq!(r.prompt(), "first\nsecond");
752    }
753
754    #[test]
755    fn ignored_fields_reports_only_what_the_request_carried() {
756        // T-09 and T-10 are two halves of one rule. Reporting a field the
757        // client never sent tells it the request was altered when it was not.
758        let both = parse(json!({"input": "x", "tools": [], "include": []}));
759        assert_eq!(both.ignored_fields(), vec!["tools", "include"]);
760
761        let one = parse(json!({"input": "x", "include": []}));
762        assert_eq!(one.ignored_fields(), vec!["include"]);
763
764        let neither = parse(json!({"input": "x"}));
765        assert!(
766            neither.ignored_fields().is_empty(),
767            "a request that sent no reserved field must not be told one was ignored"
768        );
769    }
770
771    #[test]
772    fn a_reserved_field_does_not_make_the_request_unparseable() {
773        // T-08: sending `tools` must be accepted and run, not rejected. If the
774        // struct refused unknown fields this would fail at deserialisation.
775        let r = parse(json!({
776            "input": "x",
777            "tools": [{"type": "function", "name": "probe"}],
778            "metadata": {"harness_id": "chrn_copilot"}
779        }));
780        assert_eq!(r.harness_id(), Some("chrn_copilot"));
781        assert_eq!(r.prompt(), "x");
782    }
783
784    #[test]
785    fn ids_carry_the_prefix_the_object_model_requires() {
786        assert!(id_with("resp_").starts_with("resp_"));
787        assert!(id_with(SESSION_PREFIX).starts_with("sess_"));
788    }
789
790    #[test]
791    fn only_the_session_id_shape_this_server_mints_is_accepted() {
792        assert!(is_session_id(&id_with(SESSION_PREFIX)));
793        for refused in [
794            "..",
795            ".",
796            "",
797            "sess_",
798            "/etc",
799            "sess_0123456789abcdef0123456789abcdef/..",
800            "sess_0123456789abcdef0123456789abcde/",
801            "sess_0123456789ABCDEF0123456789ABCDEF",
802            "sess_0123456789abcdef0123456789abcdef0",
803            "resp_0123456789abcdef0123456789abcdef",
804        ] {
805            assert!(!is_session_id(refused), "{refused:?} is not a minted id");
806        }
807    }
808
809    #[test]
810    fn a_terminal_response_reads_back_unchanged() {
811        let store = ResponseStore::default();
812        let resp = Response {
813            id: "resp_abc".to_owned(),
814            object: "response",
815            created_at: 1,
816            status: "completed",
817            error: None,
818            previous_response_id: None,
819            model: "m".to_owned(),
820            output: Vec::new(),
821            store: true,
822            usage: None,
823            metadata: Map::new(),
824        };
825        store.put("sess_1", resp);
826
827        let got = store.get("resp_abc").expect("stored"); // Safe: test assertion
828        assert_eq!(got.status, "completed", "a terminal state must not change");
829        assert_eq!(store.session_turns("sess_1").map(|t| t.len()), Some(1));
830        assert!(store.get("resp_missing").is_none());
831    }
832}