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
A plain send writes only the response body to stdout. Add `-v` for the request
and response metadata, where lines are prefixed `*`, `>`, and `<`:
A plain send writes only the response body to stdout. Yaak also stores every
response, so the reliable way to see what happened is to ask afterwards rather
than to parse the send output:
```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
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
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
+44
View File
@@ -90,6 +90,50 @@ pub enum Commands {
/// Template function commands
#[command(alias = "func")]
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)]
+1
View File
@@ -6,6 +6,7 @@ pub mod folder;
pub mod import_export;
pub mod plugin;
pub mod request;
pub mod response;
pub mod send;
pub mod template_function;
pub mod workspace;
@@ -107,6 +107,47 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
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) {
if !matches!(request_type, RequestSchemaType::Http) {
return;
@@ -367,6 +408,7 @@ fn create(
}
validate_create_id(&payload, "request")?;
reject_non_http_payload(&payload, "request create")?;
let mut request: HttpRequest = serde_json::from_value(payload)
.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()
@@ -415,6 +457,7 @@ fn create(
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "request update")?;
let id = require_id(&patch, "request update")?;
reject_non_http_payload(&patch, "request update")?;
let existing = ctx
.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 {
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) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await;