polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Questions data adapter (`#1660`).
//!
//! Unlike [`crate::data::approvals`] (forensics `GET` for reading,
//! `ApprovalService.Respond` for writing), this pane goes through
//! `QuestionDialer` for BOTH sides: `QuestionService.ListPending` reads the
//! outstanding inbox directly — no forensics questions projection exists (a
//! follow-up slice), and `ListPending` is already a real, self-sufficient
//! recovery read on its own, the same role `ApprovalService.ListPending`
//! plays for approvals — and `QuestionService.Respond` submits the decision,
//! THIN-signed server-side exactly like `ApprovalService.Respond`.

use anyhow::{Context, Result};
use polyc_rpc_client::{QuestionChoice, QuestionDialer, QuestionOutcome as QuestionRpcOutcome};

use crate::action::QuestionDecision;
use crate::components::questions::{QuestionOptionView, QuestionView};

/// Load the conversation's outstanding questions via `QuestionService.ListPending`,
/// projected to [`QuestionView`]s.
///
/// # Errors
/// Returns an error if the address is invalid or the RPC fails.
pub(crate) async fn load_questions(
    agent_addr: &str,
    conversation_id: &str,
) -> Result<Vec<QuestionView>> {
    let dialer = QuestionDialer::new(agent_addr)
        .with_context(|| format!("invalid agent address {agent_addr:?}"))?;
    let pending = dialer
        .list_pending(conversation_id)
        .await
        .with_context(|| format!("QuestionService.ListPending for {conversation_id}"))?;
    Ok(pending
        .into_iter()
        .map(|p| QuestionView {
            conversation_id: conversation_id.to_owned(),
            call_id: p.call_id,
            index: p.index,
            header: p.header,
            question: p.question,
            options: p
                .options
                .into_iter()
                .map(|o| QuestionOptionView {
                    label: o.label,
                    description: o.description,
                    recommended: o.recommended,
                })
                .collect(),
            args_json: p.args_json,
            answer_token: p.answer_token,
            turn_id: p.turn_id,
            response: None,
        })
        .collect())
}

/// Submit a question decision through the THIN path: dial
/// `QuestionService.Respond` at `agent_addr` via [`QuestionDialer`] and return
/// the persisted outcome (the control plane is the signer — we submit an
/// UNSIGNED decision and it returns the server-signed result). Mirrors
/// [`crate::data::approvals::submit_decision`]'s own THIN-path shape.
///
/// # Errors
/// Returns an error if the address is invalid or the RPC fails.
pub(crate) async fn submit_decision(
    agent_addr: &str,
    decision: &QuestionDecision,
) -> Result<QuestionRpcOutcome> {
    let choice = decision
        .selected_index
        .map_or(QuestionChoice::Decline, QuestionChoice::SelectOption);
    let outcome = QuestionDialer::new(agent_addr)
        .with_context(|| format!("invalid agent address {agent_addr:?}"))?
        .respond(
            &decision.turn_id,
            &decision.call_id,
            decision.index,
            choice,
            &decision.conversation_id,
            &decision.answer_token,
            None,
        )
        .await
        .with_context(|| format!("QuestionService.Respond at {agent_addr}"))?;
    Ok(outcome)
}