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