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