Add response access and stop mangling non-HTTP request payloads

Stored responses were unreachable from the CLI even though the model
layer has had list/get/delete all along. After a send, an agent could
only see the body it just streamed: no status, no timing, no history.
`yaak response list|show|body|delete` closes that. `show` and `body`
accept a request ID as shorthand for its most recent response, which is
the common case, and `show` returns status, reason, timing, headers, the
final URL, and any transport error as JSON.

`request create` also accepted payloads for request types it cannot
create. A gRPC-shaped payload deserialized into an HttpRequest with the
unknown fields dropped, so the gRPC method name landed in `method`,
`service` vanished, and the result looked like a successful create. It
now rejects a non-`http_request` `model`, and any field that is not part
of the HTTP request schema, pointing at the app instead. `request update`
had the same silent-drop behavior and gets the same check.

The skill now points at `response show` rather than teaching agents to
grep verbose send output for the status line.
This commit is contained in:
Gregory Schier
2026-08-13 22:02:47 -07:00
parent 74369e8f23
commit f53887a114
6 changed files with 254 additions and 4 deletions
+12 -4
View File
@@ -110,18 +110,26 @@ committed `yaak export` plus `--data-dir ./.yaak` gives a runnable suite in CI.
## Reading results ## Reading results
A plain send writes only the response body to stdout. Add `-v` for the request A plain send writes only the response body to stdout. Yaak also stores every
and response metadata, where lines are prefixed `*`, `>`, and `<`: response, so the reliable way to see what happened is to ask afterwards rather
than to parse the send output:
```bash ```bash
yaak -v request send rq_abc123 2>&1 | grep '^< HTTP' yaak response show rq_abc123 # latest response for a request, as JSON
yaak response list rq_abc123 # its history, newest first
yaak response body rq_abc123 # just the body
``` ```
`response show` gives status, reason, timing, headers, the final URL, and any
transport error. Pass a response ID for a specific one. `-v` on a send prints
the same information live, prefixed `*`, `>`, and `<`, but interleaves it with
the body on stdout, so prefer `response show` when you need to act on the result.
Exit code 1 means the send did not complete: an unresolved template variable, an Exit code 1 means the send did not complete: an unresolved template variable, an
unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like unreachable host, a TLS failure. **HTTP error statuses are not failures.** Like
`curl`, a 404 or 500 exits 0, and a folder of requests that all return 500 `curl`, a 404 or 500 exits 0, and a folder of requests that all return 500
reports success. Never tell the user an API is healthy based on a clean exit; reports success. Never tell the user an API is healthy based on a clean exit;
check the status yourself with `-v`. check the status.
## Execution rules ## Execution rules
+44
View File
@@ -90,6 +90,50 @@ pub enum Commands {
/// Template function commands /// Template function commands
#[command(alias = "func")] #[command(alias = "func")]
TemplateFunction(TemplateFunctionArgs), TemplateFunction(TemplateFunctionArgs),
/// Response commands
Response(ResponseArgs),
}
#[derive(Args)]
pub struct ResponseArgs {
#[command(subcommand)]
pub command: ResponseCommands,
}
#[derive(Subcommand)]
pub enum ResponseCommands {
/// List stored responses for a request or workspace
List {
/// Request or workspace ID (optional when exactly one workspace exists)
id: Option<String>,
/// Maximum number of responses to return, newest first
#[arg(long)]
limit: Option<u64>,
},
/// Show a response as JSON, including status, timing, and headers
Show {
/// Response ID, or a request ID to use its most recent response
id: String,
},
/// Write a stored response body to stdout
Body {
/// Response ID, or a request ID to use its most recent response
id: String,
},
/// Delete stored responses for a request or workspace
Delete {
/// Request or workspace ID
id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
} }
#[derive(Args)] #[derive(Args)]
+1
View File
@@ -6,6 +6,7 @@ pub mod folder;
pub mod import_export; pub mod import_export;
pub mod plugin; pub mod plugin;
pub mod request; pub mod request;
pub mod response;
pub mod send; pub mod send;
pub mod template_function; pub mod template_function;
pub mod workspace; pub mod workspace;
@@ -107,6 +107,47 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
Ok(()) Ok(())
} }
/// `request create` only builds HTTP requests, but `request schema` will happily hand
/// out gRPC and WebSocket schemas. Without this, a gRPC payload deserializes into an
/// HttpRequest with its unknown fields dropped, quietly producing a broken request
/// (the gRPC method name lands in `method`, and `service` disappears).
fn reject_non_http_payload(payload: &Value, context: &str) -> CommandResult {
let Some(object) = payload.as_object() else {
return Ok(());
};
if let Some(model) = object.get("model").and_then(Value::as_str)
&& !model.is_empty()
&& model != "http_request"
{
return Err(format!(
"{context} only supports HTTP requests, but the payload has \"model\": \"{model}\". The CLI cannot work with {model} requests yet; use the Yaak app instead."
));
}
let known: std::collections::BTreeSet<String> = serde_json::to_value(schema_for!(HttpRequest))
.ok()
.and_then(|schema| schema.get("properties").and_then(Value::as_object).cloned())
.map(|properties| properties.keys().cloned().collect())
.unwrap_or_default();
if known.is_empty() {
return Ok(());
}
let unknown: Vec<&str> =
object.keys().filter(|key| !known.contains(*key)).map(String::as_str).collect();
if !unknown.is_empty() {
return Err(format!(
"{context} got fields that are not part of an HTTP request: {}. If this is a gRPC or WebSocket request, the CLI cannot work with it yet. Run `yaak request schema http` for the valid fields.",
unknown.join(", ")
));
}
Ok(())
}
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) { fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if !matches!(request_type, RequestSchemaType::Http) { if !matches!(request_type, RequestSchemaType::Http) {
return; return;
@@ -367,6 +408,7 @@ fn create(
} }
validate_create_id(&payload, "request")?; validate_create_id(&payload, "request")?;
reject_non_http_payload(&payload, "request create")?;
let mut request: HttpRequest = serde_json::from_value(payload) let mut request: HttpRequest = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?; .map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty() let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
@@ -415,6 +457,7 @@ fn create(
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult { fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "request update")?; let patch = parse_required_json(json, json_input, "request update")?;
let id = require_id(&patch, "request update")?; let id = require_id(&patch, "request update")?;
reject_non_http_payload(&patch, "request update")?;
let existing = ctx let existing = ctx
.db() .db()
@@ -0,0 +1,148 @@
use crate::cli::{ResponseArgs, ResponseCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::workspace::resolve_workspace_id;
use std::io::Write;
use yaak_models::models::HttpResponse;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: ResponseArgs) -> i32 {
let result = match args.command {
ResponseCommands::List { id, limit } => list(ctx, id.as_deref(), limit),
ResponseCommands::Show { id } => show(ctx, &id),
ResponseCommands::Body { id } => body(ctx, &id),
ResponseCommands::Delete { id, yes } => delete(ctx, &id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
/// Accepts a response ID, or a request ID meaning "the latest response for that request".
fn resolve_response(ctx: &CliContext, id: &str) -> CommandResult<HttpResponse> {
if let Ok(response) = ctx.db().get_http_response(id) {
return Ok(response);
}
if ctx.db().get_http_request(id).is_ok() {
return ctx
.db()
.list_http_responses_for_request(id, Some(1))
.map_err(|e| format!("Failed to list responses: {e}"))?
.into_iter()
.next()
.ok_or_else(|| format!("Request {id} has no stored responses yet"));
}
Err(format!("Could not resolve ID '{id}' as a response or request"))
}
fn list(ctx: &CliContext, id: Option<&str>, limit: Option<u64>) -> CommandResult {
// An explicit request ID scopes to that request; anything else lists the workspace.
let responses = match id {
Some(id) if ctx.db().get_http_request(id).is_ok() => ctx
.db()
.list_http_responses_for_request(id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?,
other => {
let workspace_id = resolve_workspace_id(ctx, other, "response list")?;
ctx.db()
.list_http_responses(&workspace_id, limit)
.map_err(|e| format!("Failed to list responses: {e}"))?
}
};
if responses.is_empty() {
println!("No responses found");
return Ok(());
}
for response in responses {
let status = match response.error {
// Errors can be a paragraph of nested debug output; `show` has the full text.
Some(error) => {
let first_line = error.lines().next().unwrap_or_default();
let summary = match first_line.char_indices().nth(80) {
Some((cut, _)) => format!("{}...", &first_line[..cut]),
None => first_line.to_string(),
};
format!("ERROR {summary}")
}
None => {
let reason = response.status_reason.unwrap_or_default();
format!("{} {}", response.status, reason).trim_end().to_string()
}
};
println!("{} - {} {} ({}ms)", response.id, status, response.url, response.elapsed);
}
Ok(())
}
fn show(ctx: &CliContext, id: &str) -> CommandResult {
let response = resolve_response(ctx, id)?;
let output = serde_json::to_string_pretty(&response)
.map_err(|e| format!("Failed to serialize response: {e}"))?;
println!("{output}");
Ok(())
}
fn body(ctx: &CliContext, id: &str) -> CommandResult {
let response = resolve_response(ctx, id)?;
let Some(body_path) = response.body_path else {
return Err(format!("Response {} has no stored body", response.id));
};
let bytes = std::fs::read(&body_path)
.map_err(|e| format!("Failed to read response body at {body_path}: {e}"))?;
let mut stdout = std::io::stdout();
stdout.write_all(&bytes).map_err(|e| format!("Failed to write response body: {e}"))?;
stdout.flush().map_err(|e| format!("Failed to flush response body: {e}"))?;
Ok(())
}
fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
// Deleting is scoped to a whole request or workspace, since a single stored
// response is rarely the thing someone wants to remove.
if ctx.db().get_http_request(id).is_ok() {
if !yes && !confirm_delete("responses for request", id) {
println!("Aborted");
return Ok(());
}
// The delete helpers return no count, so take one first to report honestly.
let count = ctx
.db()
.list_http_responses_for_request(id, None)
.map_err(|e| format!("Failed to list responses: {e}"))?
.len();
ctx.db()
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for request {id}");
return Ok(());
}
let workspace_id = resolve_workspace_id(ctx, Some(id), "response delete")?;
if !yes && !confirm_delete("responses for workspace", &workspace_id) {
println!("Aborted");
return Ok(());
}
let count = ctx
.db()
.list_http_responses(&workspace_id, None)
.map_err(|e| format!("Failed to list responses: {e}"))?
.len();
ctx.db()
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete responses: {e}"))?;
println!("Deleted {count} responses for workspace {workspace_id}");
Ok(())
}
+6
View File
@@ -37,6 +37,12 @@ async fn main() {
let exit_code = match command { let exit_code = match command {
Commands::Agent(args) => commands::agent::run(args), Commands::Agent(args) => commands::agent::run(args),
Commands::Response(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::response::run(&context, args);
context.shutdown().await;
exit_code
}
Commands::TemplateFunction(args) => { Commands::TemplateFunction(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id); let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await; context.init_plugins(CliExecutionContext::default()).await;