fix: resource-back companion progress notice

This commit is contained in:
Keisuke Hirata 2026-06-13 00:38:52 +09:00
parent 724b79f1c0
commit 61e6c0683c
No known key found for this signature in database
5 changed files with 137 additions and 57 deletions

1
Cargo.lock generated
View File

@ -3962,6 +3962,7 @@ dependencies = [
"crossterm 0.28.1", "crossterm 0.28.1",
"llm-worker", "llm-worker",
"manifest", "manifest",
"minijinja",
"pod-registry", "pod-registry",
"pod-store", "pod-store",
"protocol", "protocol",

View File

@ -22,6 +22,7 @@ pod-registry = { workspace = true }
provider = { workspace = true } provider = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
minijinja = "2.19.0"
pulldown-cmark = { version = "0.13.3", default-features = false } pulldown-cmark = { version = "0.13.3", default-features = false }
llm-worker.workspace = true llm-worker.workspace = true

View File

@ -21,6 +21,7 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore; use session_store::FsStore;
use ticket::config::TicketConfig; use ticket::config::TicketConfig;
use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState}; use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState};
@ -52,6 +53,8 @@ const CLOSED_VISIBLE_ROWS: usize = 3;
const COMPANION_PROGRESS_MAX_TICKETS: usize = 5; const COMPANION_PROGRESS_MAX_TICKETS: usize = 5;
const COMPANION_PROGRESS_MAX_TITLE_CHARS: usize = 80; const COMPANION_PROGRESS_MAX_TITLE_CHARS: usize = 80;
const COMPANION_PROGRESS_MAX_MESSAGE_CHARS: usize = 1_800; const COMPANION_PROGRESS_MAX_MESSAGE_CHARS: usize = 1_800;
const COMPANION_PROGRESS_NOTICE_TEMPLATE: &str =
include_str!("../../../resources/prompts/panel/companion_progress_notice.md");
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100); const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@ -573,6 +576,35 @@ impl CompanionProgressNoticeResult {
} }
} }
#[derive(Debug, Serialize)]
struct CompanionProgressTemplateContext {
companion: CompanionProgressTemplateRole,
orchestrator: CompanionProgressTemplateRole,
tickets: Vec<CompanionProgressTemplateTicket>,
omitted_ticket_count: usize,
role_pods: Vec<CompanionProgressTemplateRolePod>,
}
#[derive(Debug, Serialize)]
struct CompanionProgressTemplateRole {
pod_name: String,
status: String,
}
#[derive(Debug, Serialize)]
struct CompanionProgressTemplateTicket {
id: String,
state: String,
title: String,
reference: String,
}
#[derive(Debug, Serialize)]
struct CompanionProgressTemplateRolePod {
name: String,
status: String,
}
pub(crate) struct MultiPodApp { pub(crate) struct MultiPodApp {
pub(crate) list: PodList, pub(crate) list: PodList,
pub(crate) panel: WorkspacePanelViewModel, pub(crate) panel: WorkspacePanelViewModel,
@ -2426,57 +2458,44 @@ fn companion_progress_notice(
) -> Option<CompanionProgressNotice> { ) -> Option<CompanionProgressNotice> {
let companion = panel.header.companion.as_ref()?; let companion = panel.header.companion.as_ref()?;
let orchestrator = panel.header.orchestrator.as_ref()?; let orchestrator = panel.header.orchestrator.as_ref()?;
let mut lines = vec![ let ticket_rows = panel
"Orchestrator progress context (read-only weak notification; no auto-run).".to_string(), .rows
"Reason: workspace Panel refreshed bounded orchestration progress for Companion explanation." .iter()
.to_string(), .filter_map(|row| row.ticket.as_ref().map(|ticket| (row, ticket)))
format!( .collect::<Vec<_>>();
"Roles: Companion {} is {}; Orchestrator {} is {}.", let tickets = ticket_rows
companion.pod_name, .iter()
companion.status.label(), .take(COMPANION_PROGRESS_MAX_TICKETS)
orchestrator.pod_name, .map(|(row, ticket)| CompanionProgressTemplateTicket {
orchestrator.status.label() id: bounded_progress_text(&ticket.id, COMPANION_PROGRESS_MAX_TITLE_CHARS),
state: bounded_progress_text(&row.status, COMPANION_PROGRESS_MAX_TITLE_CHARS),
title: bounded_progress_text(&row.title, COMPANION_PROGRESS_MAX_TITLE_CHARS),
reference: format!(".yoi/tickets/{}", ticket.id),
})
.collect::<Vec<_>>();
let context = CompanionProgressTemplateContext {
companion: CompanionProgressTemplateRole {
pod_name: bounded_progress_text(
&companion.pod_name,
COMPANION_PROGRESS_MAX_TITLE_CHARS,
), ),
]; status: companion.status.label().to_string(),
},
let mut ticket_lines = Vec::new(); orchestrator: CompanionProgressTemplateRole {
for row in panel.rows.iter().take(COMPANION_PROGRESS_MAX_TICKETS) { pod_name: bounded_progress_text(
let ticket_id = row &orchestrator.pod_name,
.ticket COMPANION_PROGRESS_MAX_TITLE_CHARS,
.as_ref() ),
.map(|ticket| ticket.id.as_str()) status: orchestrator.status.label().to_string(),
.unwrap_or("unknown-ticket"); },
ticket_lines.push(format!( tickets,
"- {} [{}] {} (ref: .yoi/tickets/{})", omitted_ticket_count: ticket_rows
ticket_id, .len()
row.status, .saturating_sub(COMPANION_PROGRESS_MAX_TICKETS),
bounded_progress_text(&row.title, COMPANION_PROGRESS_MAX_TITLE_CHARS), role_pods: bounded_role_pod_values(list, companion, orchestrator),
ticket_id };
)); let rendered = render_companion_progress_notice_template(&context).ok()?;
} let message = bounded_progress_text(&rendered, COMPANION_PROGRESS_MAX_MESSAGE_CHARS);
if ticket_lines.is_empty() {
lines.push("Tickets: none visible in the current Panel snapshot.".to_string());
} else {
lines.push(format!(
"Tickets (first {} visible, bounded):",
ticket_lines.len()
));
lines.extend(ticket_lines);
if panel.rows.len() > COMPANION_PROGRESS_MAX_TICKETS {
lines.push(format!(
"- … {} more ticket(s) omitted from this bounded notice.",
panel.rows.len() - COMPANION_PROGRESS_MAX_TICKETS
));
}
}
let role_pod_lines = bounded_role_pod_lines(list, companion, orchestrator);
if !role_pod_lines.is_empty() {
lines.push("Role pod status snapshot:".to_string());
lines.extend(role_pod_lines);
}
let message = bounded_progress_text(&lines.join("\n"), COMPANION_PROGRESS_MAX_MESSAGE_CHARS);
let fingerprint = message.clone(); let fingerprint = message.clone();
Some(CompanionProgressNotice { Some(CompanionProgressNotice {
message, message,
@ -2484,19 +2503,35 @@ fn companion_progress_notice(
}) })
} }
fn bounded_role_pod_lines( fn render_companion_progress_notice_template(
context: &CompanionProgressTemplateContext,
) -> Result<String, minijinja::Error> {
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.add_template(
"companion_progress_notice",
COMPANION_PROGRESS_NOTICE_TEMPLATE,
)?;
env.get_template("companion_progress_notice")?
.render(context)
}
fn bounded_role_pod_values(
list: &PodList, list: &PodList,
companion: &CompanionPanelState, companion: &CompanionPanelState,
orchestrator: &OrchestratorPanelState, orchestrator: &OrchestratorPanelState,
) -> Vec<String> { ) -> Vec<CompanionProgressTemplateRolePod> {
let mut lines = Vec::new(); let mut role_pods = Vec::new();
for name in [&companion.pod_name, &orchestrator.pod_name] { for name in [&companion.pod_name, &orchestrator.pod_name] {
let Some(entry) = list.entries.iter().find(|entry| entry.name == *name) else { let Some(entry) = list.entries.iter().find(|entry| entry.name == *name) else {
continue; continue;
}; };
lines.push(format!("- {}: {}", entry.name, row_status_label(entry).0)); role_pods.push(CompanionProgressTemplateRolePod {
name: bounded_progress_text(&entry.name, COMPANION_PROGRESS_MAX_TITLE_CHARS),
status: row_status_label(entry).0.to_string(),
});
} }
lines role_pods
} }
fn bounded_progress_text(input: &str, max_chars: usize) -> String { fn bounded_progress_text(input: &str, max_chars: usize) -> String {
@ -4987,6 +5022,37 @@ mod tests {
); );
} }
#[test]
fn companion_progress_notice_uses_prompt_resource_template() {
let first_resource_line = COMPANION_PROGRESS_NOTICE_TEMPLATE.lines().next().unwrap();
let context = CompanionProgressTemplateContext {
companion: CompanionProgressTemplateRole {
pod_name: "yoi".to_string(),
status: "Live".to_string(),
},
orchestrator: CompanionProgressTemplateRole {
pod_name: "test-orchestrator".to_string(),
status: "Live".to_string(),
},
tickets: vec![CompanionProgressTemplateTicket {
id: "RESOURCE-TICKET".to_string(),
state: "inprogress".to_string(),
title: "Rendered from runtime values".to_string(),
reference: ".yoi/tickets/RESOURCE-TICKET".to_string(),
}],
omitted_ticket_count: 0,
role_pods: vec![CompanionProgressTemplateRolePod {
name: "yoi".to_string(),
status: "idle".to_string(),
}],
};
let rendered = render_companion_progress_notice_template(&context).unwrap();
assert!(rendered.contains(first_resource_line));
assert!(rendered.contains("RESOURCE-TICKET"));
assert!(rendered.contains("Rendered from runtime values"));
}
#[test] #[test]
fn companion_progress_notice_is_bounded_and_excludes_sensitive_unbounded_fields() { fn companion_progress_notice_is_bounded_and_excludes_sensitive_unbounded_fields() {
let mut app = ticket_enabled_app(vec![ let mut app = ticket_enabled_app(vec![

View File

@ -40,7 +40,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-WvMpHbTswYeRrkw5I4V4E1RnG7j13PbuQCbeas/XILs="; cargoHash = "sha256-o47Erp9UrS2Rgwd0JNpuYPO4pZmv62DkzY9KXMQpyAM=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -0,0 +1,12 @@
Orchestrator progress context (read-only weak notification; no auto-run).
Reason: workspace Panel refreshed bounded orchestration progress for Companion explanation.
Roles: Companion {{ companion.pod_name }} is {{ companion.status }}; Orchestrator {{ orchestrator.pod_name }} is {{ orchestrator.status }}.
{% if tickets %}Tickets (first {{ tickets | length }} visible, bounded):
{% for ticket in tickets %}- {{ ticket.id }} [{{ ticket.state }}] {{ ticket.title }} (ref: {{ ticket.reference }})
{% endfor %}{% if omitted_ticket_count > 0 %}- … {{ omitted_ticket_count }} more ticket(s) omitted from this bounded notice.
{% endif %}{% else %}Tickets: none visible in the current Panel snapshot.
{% endif %}{% if role_pods %}
Role pod status snapshot:
{% for role_pod in role_pods %}- {{ role_pod.name }}: {{ role_pod.status }}
{% endfor %}{% endif %}