From 6d6d5df20e237be82094b28c02f8649cd48fd8cb Mon Sep 17 00:00:00 2001 From: Ben Wisecup Date: Mon, 6 Jul 2026 17:54:58 -0400 Subject: [PATCH] feat(advisor): render ADR deep-links in advisor output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisor's related-ADR list now prints each ADR's deep link on its own line, so a reader can click straight through to the decision in the app. The link comes from the advisor response and is rendered verbatim; a null or empty link is skipped so the ADR still prints cleanly. The response struct previously discarded the server's per-ADR url field during deserialization. Capture it as an optional, backwards-compatible field (absent or null becomes None) so it can be rendered. Generated by the operator's software factory. City: factory-main ยท Agent: local-core.builder-2 On behalf of: @benw5483 Co-Authored-By: .invalid> --- src/api/types.rs | 38 ++++++++++++++++++++++++++++++++++++- src/cli/commands/advisor.rs | 35 ++++++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/api/types.rs b/src/api/types.rs index 8d01b526..0cbd1b22 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -335,6 +335,13 @@ pub struct RelatedAdr { pub scope: String, pub relevance_reason: String, pub confidence: f64, + /// Fully-formed, environment-aware deep link back to this ADR in the + /// Decisions UI. The server assembles it, so it is used verbatim and never + /// reconstructed here. Nullable on the wire (an interpreter-layer result + /// can carry `null` before the link is populated) and absent from older + /// servers, so both cases deserialize to `None`. + #[serde(default)] + pub url: Option, } /// Outcome of a single poll of `GET /v1/advisor/query/:id`. @@ -1019,7 +1026,8 @@ mod tests { "related_adrs": [{ "id": "a1", "name": "n1", "title": "t1", "policy": "p1", "instructions": "i1", "scope": "s1", "relevance_reason": "r1", - "confidence": 0.75 + "confidence": 0.75, + "url": "https://app.example.com/decisions/r1?tab=active&decision=abc1234" }] } }, @@ -1031,5 +1039,33 @@ mod tests { assert_eq!(out.summary, "top-level summary"); assert_eq!(out.interpreter.related_adrs.len(), 1); assert_eq!(out.interpreter.related_adrs[0].confidence, 0.75); + // The server's deep link is captured verbatim. + assert_eq!( + out.interpreter.related_adrs[0].url.as_deref(), + Some("https://app.example.com/decisions/r1?tab=active&decision=abc1234") + ); + } + + #[test] + fn test_related_adr_url_absent_or_null_is_none() { + // `url` absent (older server) and `url: null` (interpreter-layer result + // before the link is populated) both deserialize to None. + let json = r#"{ + "summary": "s", + "interpreter": { + "summary": "i", + "related_adrs": [ + { "id": "a1", "name": "n1", "title": "t1", "policy": "p1", + "instructions": "i1", "scope": "s1", "relevance_reason": "r1", + "confidence": 0.5 }, + { "id": "a2", "name": "n2", "title": "t2", "policy": "p2", + "instructions": "i2", "scope": "s2", "relevance_reason": "r2", + "confidence": 0.6, "url": null } + ] + } + }"#; + let out: AdvisorOutput = serde_json::from_str(json).unwrap(); + assert_eq!(out.interpreter.related_adrs[0].url, None); + assert_eq!(out.interpreter.related_adrs[1].url, None); } } diff --git a/src/cli/commands/advisor.rs b/src/cli/commands/advisor.rs index ce5bbaa3..fd2cb7e5 100644 --- a/src/cli/commands/advisor.rs +++ b/src/cli/commands/advisor.rs @@ -233,6 +233,11 @@ fn print_answer(output: &AdvisorOutput) { adr.scope, adr.confidence * 100.0 ); + // Render the server-provided deep link (used verbatim) on its own + // line; skip a null or empty url so the ADR still prints cleanly. + if let Some(url) = adr.url.as_deref().filter(|u| !u.is_empty()) { + println!(" {url}"); + } } } } @@ -309,20 +314,30 @@ mod tests { #[test] fn test_print_answer_with_and_without_adrs() { + let adr = |url: Option<&str>| crate::api::types::RelatedAdr { + id: "a".to_string(), + name: "n".to_string(), + title: "T".to_string(), + policy: "p".to_string(), + instructions: "i".to_string(), + scope: "s".to_string(), + relevance_reason: "r".to_string(), + confidence: 0.5, + url: url.map(|u| u.to_string()), + }; + // Cover all three url arms: a populated link renders, while a null or + // an empty link is skipped without breaking the ADR line. let with = AdvisorOutput { summary: "S".to_string(), interpreter: crate::api::types::AdvisorInterpreter { summary: "i".to_string(), - related_adrs: vec![crate::api::types::RelatedAdr { - id: "a".to_string(), - name: "n".to_string(), - title: "T".to_string(), - policy: "p".to_string(), - instructions: "i".to_string(), - scope: "s".to_string(), - relevance_reason: "r".to_string(), - confidence: 0.5, - }], + related_adrs: vec![ + adr(Some( + "https://app.example.com/decisions/r1?tab=active&decision=abc1234", + )), + adr(None), + adr(Some("")), + ], }, }; print_answer(&with);