fix: render ticket event notice from prompt resource

This commit is contained in:
Keisuke Hirata 2026-06-13 13:08:12 +09:00
parent 465ef1004b
commit 6f8571f77f
No known key found for this signature in database
4 changed files with 81 additions and 14 deletions

View File

@ -95,6 +95,8 @@ pub enum PodPrompt {
/// Trailing Pod orchestration guidance, appended when registered tools /// Trailing Pod orchestration guidance, appended when registered tools
/// include Pod-management capabilities. /// include Pod-management capabilities.
PodOrchestrationGuidanceSection, PodOrchestrationGuidanceSection,
/// Weak Companion Notify payload for explicit Orchestrator Ticket events.
TicketEventCompanionNotice,
/// LLM-facing description for the SpawnPod tool, including discovered /// LLM-facing description for the SpawnPod tool, including discovered
/// profile selectors. /// profile selectors.
SpawnPodToolDescription, SpawnPodToolDescription,
@ -115,6 +117,7 @@ impl PodPrompt {
Self::ResidentKnowledgeSection => "resident_knowledge_section", Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section", Self::ResidentWorkflowsSection => "resident_workflows_section",
Self::PodOrchestrationGuidanceSection => "pod_orchestration_guidance_section", Self::PodOrchestrationGuidanceSection => "pod_orchestration_guidance_section",
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
Self::SpawnPodToolDescription => "spawn_pod_tool_description", Self::SpawnPodToolDescription => "spawn_pod_tool_description",
} }
} }
@ -135,6 +138,7 @@ impl PodPrompt {
PodPrompt::ResidentKnowledgeSection, PodPrompt::ResidentKnowledgeSection,
PodPrompt::ResidentWorkflowsSection, PodPrompt::ResidentWorkflowsSection,
PodPrompt::PodOrchestrationGuidanceSection, PodPrompt::PodOrchestrationGuidanceSection,
PodPrompt::TicketEventCompanionNotice,
PodPrompt::SpawnPodToolDescription, PodPrompt::SpawnPodToolDescription,
]; ];
@ -151,6 +155,7 @@ impl PodPrompt {
"resident_knowledge_section", "resident_knowledge_section",
"resident_workflows_section", "resident_workflows_section",
"pod_orchestration_guidance_section", "pod_orchestration_guidance_section",
"ticket_event_companion_notice",
"spawn_pod_tool_description", "spawn_pod_tool_description",
]; ];
} }

View File

@ -1,12 +1,15 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use minijinja::Value as TemplateValue;
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap;
use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug}; use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug};
use tracing::debug; use tracing::debug;
use crate::discovery::PodDiscovery; use crate::discovery::PodDiscovery;
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary}; use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
use crate::prompt::catalog::{PodPrompt, PromptCatalog};
use pod_store::PodMetadataStore; use pod_store::PodMetadataStore;
const MAX_TITLE_CHARS: usize = 96; const MAX_TITLE_CHARS: usize = 96;
@ -89,28 +92,56 @@ fn build_ticket_event_notice(
.ok()?; .ok()?;
let event_kind = sanitize_one_line(&event_kind, MAX_EVENT_KIND_CHARS); let event_kind = sanitize_one_line(&event_kind, MAX_EVENT_KIND_CHARS);
let ticket_id = ticket.meta.id.as_str();
let title = sanitize_one_line(&ticket.meta.title, MAX_TITLE_CHARS); let title = sanitize_one_line(&ticket.meta.title, MAX_TITLE_CHARS);
let state = ticket.meta.workflow_state.as_str(); let state = ticket.meta.workflow_state.as_str();
let output_summary = sanitize_one_line(&output.summary, MAX_SUMMARY_CHARS); let output_summary = sanitize_one_line(&output.summary, MAX_SUMMARY_CHARS);
let ref_path = event_ref_path(&ticket.meta.id, summary.tool_name.as_str()); let ref_path = event_ref_path(ticket_id, summary.tool_name.as_str());
let raw_message = format!( let message = render_ticket_event_notice_message(TicketEventNoticeValues {
"Ticket event notice (weak; auto_run=false)\n\ ticket_id,
ticket: {ticket_id}\n\ title: &title,
title: {title}\n\ state,
state: {state}\n\ event_kind: &event_kind,
event: {event_kind}\n\ summary: &output_summary,
summary: {output_summary}\n\ ref_path: &ref_path,
ref: {ref_path}", })?;
ticket_id = ticket.meta.id.as_str(),
);
Some(TicketEventNotice { Some(TicketEventNotice {
ticket_id: ticket.meta.id.as_str().to_string(), ticket_id: ticket_id.to_string(),
event_kind, event_kind,
message: bound_chars(&raw_message, MAX_MESSAGE_CHARS), message: bound_chars(&message, MAX_MESSAGE_CHARS),
}) })
} }
struct TicketEventNoticeValues<'a> {
ticket_id: &'a str,
title: &'a str,
state: &'a str,
event_kind: &'a str,
summary: &'a str,
ref_path: &'a str,
}
fn render_ticket_event_notice_message(values: TicketEventNoticeValues<'_>) -> Option<String> {
PromptCatalog::builtins_only()
.ok()?
.render(PodPrompt::TicketEventCompanionNotice, values.to_template())
.ok()
}
impl TicketEventNoticeValues<'_> {
fn to_template(&self) -> TemplateValue {
let mut values: BTreeMap<&'static str, TemplateValue> = BTreeMap::new();
values.insert("ticket_id", TemplateValue::from(self.ticket_id));
values.insert("title", TemplateValue::from(self.title));
values.insert("state", TemplateValue::from(self.state));
values.insert("event_kind", TemplateValue::from(self.event_kind));
values.insert("summary", TemplateValue::from(self.summary));
values.insert("ref_path", TemplateValue::from(self.ref_path));
TemplateValue::from(values)
}
}
fn explicit_ticket_event_kind(tool_name: &str, content: &Value) -> Option<String> { fn explicit_ticket_event_kind(tool_name: &str, content: &Value) -> Option<String> {
match tool_name { match tool_name {
"TicketComment" => content "TicketComment" => content
@ -230,6 +261,29 @@ mod tests {
assert!(notice.message.contains("event: state/queued->inprogress")); assert!(notice.message.contains("event: state/queued->inprogress"));
assert!(notice.message.contains("ref: .yoi/tickets/")); assert!(notice.message.contains("ref: .yoi/tickets/"));
assert!(notice.message.chars().count() <= MAX_MESSAGE_CHARS + 1); assert!(notice.message.chars().count() <= MAX_MESSAGE_CHARS + 1);
let expected = PromptCatalog::builtins_only()
.expect("load prompt catalog")
.render(
PodPrompt::TicketEventCompanionNotice,
TicketEventNoticeValues {
ticket_id: &notice.ticket_id,
title: &sanitize_one_line(
"A very long title that should be bounded but still identify the ticket precisely enough for Companion",
MAX_TITLE_CHARS,
),
state: "planning",
event_kind: "state/queued->inprogress",
summary: &sanitize_one_line(
"Changed ticket state from queued to inprogress with a deliberately long summary that should be bounded before entering the weak notification payload and should not contain large logs",
MAX_SUMMARY_CHARS,
),
ref_path: &format!(".yoi/tickets/{}/item.md", ticket_id),
}
.to_template(),
)
.expect("render prompt resource");
assert_eq!(notice.message, bound_chars(&expected, MAX_MESSAGE_CHARS));
} }
#[test] #[test]
@ -392,7 +446,6 @@ mod tests {
.await; .await;
assert_eq!(action, HookPostToolAction::Continue); assert_eq!(action, HookPostToolAction::Continue);
let message = rx.recv().await.unwrap(); let message = rx.recv().await.unwrap();
assert!(message.contains("Ticket event notice"));
assert!(message.contains("event: state/queued->inprogress")); assert!(message.contains("event: state/queued->inprogress"));
assert!(message.contains("title: Companion event hook")); assert!(message.contains("title: Companion event hook"));
companion.await.unwrap(); companion.await.unwrap();

View File

@ -68,6 +68,8 @@ The following workflows are advertised resident. When a user request matches one
pod_orchestration_guidance_section = "{% include \"$yoi/common/pod-orchestration\" %}" pod_orchestration_guidance_section = "{% include \"$yoi/common/pod-orchestration\" %}"
ticket_event_companion_notice = "{% include \"$yoi/pod/ticket_event_companion_notice\" %}"
spawn_pod_tool_description = """\ spawn_pod_tool_description = """\
Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path. Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path.

View File

@ -0,0 +1,7 @@
Ticket event notice (weak; auto_run=false)
ticket: {{ ticket_id }}
title: {{ title }}
state: {{ state }}
event: {{ event_kind }}
summary: {{ summary }}
ref: {{ ref_path }}