From 67d018fe052853c104a96d441ce175dd5ec4c39d Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 08:40:06 +0500 Subject: [PATCH] Fix: print JSON on live-poll --reply success (#800) Successful --reply was exit 0 with empty stdout, so agents could not tell delivery from a hang. Prepared with AI assistance. Co-authored-by: Cursor --- crates/live/src/live_poll.rs | 56 ++++++++++++++++++- docs/CLI-CONTRACT.md | 2 +- ...ve-daemon-server-status-poll-complete.json | 4 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/live/src/live_poll.rs b/crates/live/src/live_poll.rs index a35382aaa..ebd7ef4ea 100644 --- a/crates/live/src/live_poll.rs +++ b/crates/live/src/live_poll.rs @@ -596,6 +596,18 @@ fn write_carbonize_banner(event: &Map, io: &mut Io) { } } +fn reply_ack_json(reply: &Reply) -> Value { + let mut m = Map::new(); + m.insert("ok".into(), json!(true)); + m.insert("id".into(), json!(reply.id)); + m.insert("status".into(), json!(reply.ty)); + if let Some(f) = &reply.file { + m.insert("file".into(), json!(f)); + } + m.insert("_instructions".into(), json!("Poll again now.")); + Value::Object(m) +} + /// JS: printPollEvent(event) — a wire-supplied `_instructions` must never /// win over the locally generated one (#488). fn print_poll_event(event: &mut Value, io: &mut Io) { @@ -718,7 +730,13 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } }; return match post_reply(&base, &token, &reply) { - Ok(()) => 0, + Ok(()) => { + println( + io, + &serde_json::to_string(&reply_ack_json(&reply)).unwrap_or_default(), + ); + 0 + } Err(PollError::ConnRefused) => { io.err(&format!( "Live server not running. Start one with: {}\n", @@ -839,4 +857,40 @@ mod tests { })); assert!(parsed.get("_instructions").is_none(), "{}", parsed); } + + #[test] + fn reply_ack_json_includes_file_when_present() { + let reply = Reply { + id: "ab12cd34".into(), + ty: "done".into(), + message: None, + file: Some("index.html".into()), + data: None, + source_event_type: None, + }; + let parsed = reply_ack_json(&reply); + assert_eq!(parsed["ok"], json!(true)); + assert_eq!(parsed["id"], json!("ab12cd34")); + assert_eq!(parsed["status"], json!("done")); + assert_eq!(parsed["file"], json!("index.html")); + assert_eq!(parsed["_instructions"], json!("Poll again now.")); + } + + #[test] + fn reply_ack_json_omits_file_when_absent() { + let reply = Reply { + id: "ab12cd34".into(), + ty: "steer_done".into(), + message: None, + file: None, + data: None, + source_event_type: None, + }; + let parsed = reply_ack_json(&reply); + assert_eq!(parsed["ok"], json!(true)); + assert_eq!(parsed["id"], json!("ab12cd34")); + assert_eq!(parsed["status"], json!("steer_done")); + assert!(parsed.get("file").is_none(), "{}", parsed); + assert_eq!(parsed["_instructions"], json!("Poll again now.")); + } } diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 698705698..7d1f89d76 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1740,7 +1740,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit #### `live-poll.mjs` -> `impeccable poll` - Invoked from live.md poll loop; `--reply` forms quoted in `_instructions` (see instructions.mjs strings in 6.3/below). -- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply [--file PATH] [--data JSON] [message]`, `--help`. `--reply` errors (stderr, exit 1): `Usage: node "/live-poll.mjs" --reply [--file path] [--data ''] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: `. +- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply [--file PATH] [--data JSON] [message]`, `--help`. `--reply` success (stdout, exit 0): one compact JSON line `{ok:true,id,status,file? (only when --file was passed),_instructions:'Poll again now.'}`. `--reply` errors (stderr, exit 1): `Usage: node "/live-poll.mjs" --reply [--file path] [--data ''] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: `. - Needs `server.json`; else stderr `No running live server found. Start one with: node "/live.mjs"` exit 1. - One-shot: loops `GET /poll?token&timeout=&leaseMs=600000[&types]` until an event or total deadline; prints one JSON line (`console.log(JSON.stringify(event))`) with `_instructions` added by `instructionsForEvent` (unless already present). For `accept`/`discard`: spawns `node live-accept.mjs --id ID (--discard | --variant N) [--page-url U] [--param-values JSON]` (30 s), sets `event._acceptResult` (parse failure/throw → `{handled:false, mode:'error', error}`), then POSTs completion `{id, type: completionType, sourceEventType: event.type, message: _acceptResult.error, file: _acceptResult.file, data: {carbonize:true}?}` where completionType = discard: `discarded` if handled else `error`; accept: `agent_done` if handled&carbonize, `complete` if handled, `error` if mode error or (svelte-component unhandled), else `agent_done`; sets `event._completionAck = {ok:true, type}` (+ `final:false, requiresComplete:true, nextCommand:'live-complete.mjs --id ', message:'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.'` for carbonize) or `{ok:false, error}`. Stderr banners: manual_edit_apply → 4-line banner starting `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply done --data ''\`.`; carbonize → `⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id . See reference/live.md "Required after accept".` - Stream: stderr `[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running`; after each reply-needing event waits (poll `/status` every 400 ms) until the id leaves `pendingEvents` (else `Timed out waiting for --reply on event ` exit 1); returns on `exit`. diff --git a/tests/oracle/golden/live-daemon-server-status-poll-complete.json b/tests/oracle/golden/live-daemon-server-status-poll-complete.json index 1c44e0dbb..f0c9ec78f 100644 --- a/tests/oracle/golden/live-daemon-server-status-poll-complete.json +++ b/tests/oracle/golden/live-daemon-server-status-poll-complete.json @@ -26,7 +26,7 @@ "signal": null }, { - "stdout": "", + "stdout": "{\"ok\":true,\"id\":\"ab12cd34\",\"status\":\"done\",\"file\":\"index.html\",\"_instructions\":\"Poll again now.\"}\n", "stderr": "", "exit": 0, "signal": null @@ -44,7 +44,7 @@ "signal": null }, { - "stdout": "", + "stdout": "{\"ok\":true,\"id\":\"ab12cd34\",\"status\":\"steer_done\",\"_instructions\":\"Poll again now.\"}\n", "stderr": "", "exit": 0, "signal": null