From 92c4dee71a1e3744097a105cf470a7b76dc36fd0 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 00:08:01 +0900 Subject: [PATCH] ticket: guide Ticket tool language universally --- crates/pod/src/feature/builtin/ticket.rs | 119 ++++++++++++++++------- crates/ticket/src/tool.rs | 118 +++++++++++++++------- 2 files changed, 168 insertions(+), 69 deletions(-) diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index 471f0e97..f9cf2f05 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -12,7 +12,7 @@ use ticket::{ tool::{ TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, - TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tools, + TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tool_description, ticket_tools, }, }; @@ -178,7 +178,10 @@ impl FeatureModule for TicketFeature { )); let enabled_tool_names = self.enabled_tool_names(); for name in &enabled_tool_names { - descriptor = descriptor.with_tool(ToolDeclaration::new(*name, tool_description(name))); + descriptor = descriptor.with_tool(ToolDeclaration::new( + *name, + ticket_tool_description(name, self.record_language.as_deref()), + )); } descriptor } @@ -227,37 +230,6 @@ impl FeatureModule for TicketFeature { } } -fn tool_description(name: &str) -> &'static str { - match name { - "TicketCreate" => "Create a Ticket through the typed local Ticket backend.", - "TicketList" => { - "List Tickets as a lightweight bounded overview for id selection; use TicketShow before decisions." - } - "TicketShow" => { - "Show one Ticket through the typed local Ticket backend as the detailed authority." - } - "TicketComment" => { - "Append a comment/plan/decision/implementation_report event to a Ticket." - } - "TicketReview" => "Append an approve/request_changes review event to a Ticket.", - "TicketIntakeReady" => { - "Mark an intake Ticket ready and append the typed intake summary/state transition events." - } - "TicketWorkflowState" => { - "Transition Ticket state; queued -> inprogress is the accepted implementation start, so implementation side effects should happen only after that transition is accepted and recorded." - } - "TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.", - "TicketOrchestrationPlanRecord" => { - "Append a durable typed Ticket orchestration plan record without changing state or starting work." - } - "TicketOrchestrationPlanQuery" => { - "Query durable Ticket orchestration plan records by Ticket and/or relation kind." - } - "TicketDoctor" => "Run typed local Ticket backend consistency checks.", - _ => "Typed Ticket backend tool.", - } -} - pub fn ticket_tools_feature(workspace: impl AsRef) -> TicketFeature { TicketFeature::for_workspace(workspace) } @@ -298,6 +270,19 @@ mod tests { std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap(); } + fn pending_tool_description( + pending_tools: &[llm_worker::tool::ToolDefinition], + name: &str, + ) -> String { + pending_tools + .iter() + .find_map(|definition| { + let (meta, _) = definition(); + (meta.name == name).then_some(meta.description) + }) + .expect("tool exists") + } + #[test] fn descriptor_declares_ticket_tools_and_backend_authority() { let temp = TempDir::new().unwrap(); @@ -407,6 +392,45 @@ mod tests { } } + #[test] + fn read_only_companion_style_context_exposes_ticket_language_guidance() { + let temp = TempDir::new().unwrap(); + write_ticket_config( + temp.path(), + r#" +[ticket] +language = "Japanese" +"#, + ); + make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); + let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly); + let descriptor = feature.descriptor(); + let descriptor_description = descriptor + .tools + .iter() + .find(|tool| tool.name == "TicketShow") + .expect("TicketShow declared") + .description + .clone(); + assert!(descriptor_description.contains("Ticket record language: Japanese")); + + let mut pending_tools = Vec::new(); + let mut hooks = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(feature) + .install_into_pending(&mut pending_tools, &mut hooks); + + assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len()); + assert_eq!( + report.reports[0].installed_tools, + TICKET_READ_ONLY_TOOL_NAMES + ); + let description = pending_tool_description(&pending_tools, "TicketShow"); + assert!(description.contains("Ticket record language: Japanese")); + assert!(description.contains("distinct from worker.language")); + assert!(description.contains("Preserve protocol literals")); + } + #[test] fn lifecycle_installation_exposes_lifecycle_tools() { let temp = TempDir::new().unwrap(); @@ -444,6 +468,35 @@ mod tests { ); } + #[test] + fn lifecycle_ticket_role_style_context_exposes_ticket_language_guidance() { + let temp = TempDir::new().unwrap(); + write_ticket_config( + temp.path(), + r#" +[ticket] +language = "Japanese" +"#, + ); + make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH)); + let mut pending_tools = Vec::new(); + let mut hooks = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature_with_access( + temp.path(), + TicketFeatureAccess::Lifecycle, + )) + .install_into_pending(&mut pending_tools, &mut hooks); + + assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len()); + assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES); + let description = pending_tool_description(&pending_tools, "TicketComment"); + assert!(description.contains("Ticket record language: Japanese")); + assert!(description.contains("durable Ticket record and Ticket tool body text")); + assert!(description.contains("distinct from worker.language")); + assert!(description.contains("memory.language")); + } + #[test] fn installs_ticket_tools_when_default_root_is_usable() { let temp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 65354512..c7be9a92 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -131,6 +131,41 @@ explicit state decisions."; const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \ diagnostics through the typed backend without shelling out to external commands."; +fn base_tool_description(name: &str) -> &'static str { + match name { + "TicketCreate" => CREATE_DESCRIPTION, + "TicketList" => LIST_DESCRIPTION, + "TicketShow" => SHOW_DESCRIPTION, + "TicketComment" => COMMENT_DESCRIPTION, + "TicketReview" => REVIEW_DESCRIPTION, + "TicketIntakeReady" => INTAKE_READY_DESCRIPTION, + "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, + "TicketClose" => CLOSE_DESCRIPTION, + "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, + "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, + "TicketOrchestrationPlanRecord" => ORCHESTRATION_PLAN_RECORD_DESCRIPTION, + "TicketOrchestrationPlanQuery" => ORCHESTRATION_PLAN_QUERY_DESCRIPTION, + "TicketDoctor" => DOCTOR_DESCRIPTION, + _ => "Ticket backend tool.", + } +} + +/// Build the model-visible Ticket tool description for a configured Ticket backend. +/// +/// `record_language` is the durable Ticket record/tool-body language, distinct from +/// worker response language and Memory/Knowledge language. Keeping this on the tool +/// surface ensures every Ticket-capable Pod sees the policy without hidden context +/// injection or role-launch-only prose. +pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String { + let mut description = base_tool_description(name).to_string(); + if let Some(language) = record_language.filter(|language| !language.trim().is_empty()) { + description.push_str("\n\nTicket record language: "); + description.push_str(language.trim()); + description.push_str(". Use this language for durable Ticket record and Ticket tool body text, including Ticket item bodies, thread comments/plans/decisions/implementation reports, reviews, resolutions, intake summaries, and orchestration plan notes. This policy is distinct from worker.language for normal prose and memory.language for Memory/Knowledge. Preserve protocol literals, file paths, commands, logs, identifiers, and quoted external text when translation would reduce fidelity."); + } + description +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketCreateParams { /// Ticket title. Must not be empty. @@ -1273,18 +1308,15 @@ fn json_output(summary: String, value: impl Serialize) -> ToolOutput { } } -fn tool_definition( - name: &'static str, - description: &'static str, - backend: LocalTicketBackend, -) -> ToolDefinition +fn tool_definition(name: &'static str, backend: LocalTicketBackend) -> ToolDefinition where T: Tool + From + 'static, { + let description = ticket_tool_description(name, backend.record_language()); Arc::new(move || { let schema_value = input_schema(name); let meta = ToolMeta::new(name) - .description(description) + .description(description.clone()) .input_schema(schema_value); let tool: Arc = Arc::new(T::from(backend.clone())); (meta, tool) @@ -1348,43 +1380,25 @@ impl_from_backend!(TicketDoctorTool); /// Build all MVP Ticket tool definitions over one local backend root. pub fn ticket_tools(backend: LocalTicketBackend) -> Vec { vec![ - tool_definition::("TicketCreate", CREATE_DESCRIPTION, backend.clone()), - tool_definition::("TicketList", LIST_DESCRIPTION, backend.clone()), - tool_definition::("TicketShow", SHOW_DESCRIPTION, backend.clone()), - tool_definition::("TicketComment", COMMENT_DESCRIPTION, backend.clone()), - tool_definition::("TicketReview", REVIEW_DESCRIPTION, backend.clone()), - tool_definition::( - "TicketIntakeReady", - INTAKE_READY_DESCRIPTION, - backend.clone(), - ), - tool_definition::( - "TicketWorkflowState", - WORKFLOW_STATE_DESCRIPTION, - backend.clone(), - ), - tool_definition::("TicketClose", CLOSE_DESCRIPTION, backend.clone()), - tool_definition::( - "TicketRelationRecord", - RELATION_RECORD_DESCRIPTION, - backend.clone(), - ), - tool_definition::( - "TicketRelationQuery", - RELATION_QUERY_DESCRIPTION, - backend.clone(), - ), + tool_definition::("TicketCreate", backend.clone()), + tool_definition::("TicketList", backend.clone()), + tool_definition::("TicketShow", backend.clone()), + tool_definition::("TicketComment", backend.clone()), + tool_definition::("TicketReview", backend.clone()), + tool_definition::("TicketIntakeReady", backend.clone()), + tool_definition::("TicketWorkflowState", backend.clone()), + tool_definition::("TicketClose", backend.clone()), + tool_definition::("TicketRelationRecord", backend.clone()), + tool_definition::("TicketRelationQuery", backend.clone()), tool_definition::( "TicketOrchestrationPlanRecord", - ORCHESTRATION_PLAN_RECORD_DESCRIPTION, backend.clone(), ), tool_definition::( "TicketOrchestrationPlanQuery", - ORCHESTRATION_PLAN_QUERY_DESCRIPTION, backend.clone(), ), - tool_definition::("TicketDoctor", DOCTOR_DESCRIPTION, backend), + tool_definition::("TicketDoctor", backend), ] } @@ -1412,6 +1426,16 @@ mod tests { .expect("tool exists") } + fn tool_description_by_name(backend: LocalTicketBackend, name: &str) -> String { + ticket_tools(backend) + .into_iter() + .find_map(|definition| { + let (meta, _) = definition(); + (meta.name == name).then_some(meta.description) + }) + .expect("tool exists") + } + #[test] fn ticket_tool_name_partitions_are_explicit() { assert_eq!( @@ -1463,6 +1487,29 @@ mod tests { assert!(meta.description.contains("implementation side effects")); } + #[test] + fn tool_descriptions_include_configured_ticket_record_language_guidance() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp).with_record_language(Some("Japanese")); + let description = tool_description_by_name(backend, "TicketComment"); + + assert!(description.contains("Ticket record language: Japanese")); + assert!(description.contains("durable Ticket record and Ticket tool body text")); + assert!(description.contains("distinct from worker.language")); + assert!(description.contains("memory.language")); + assert!(description.contains("Preserve protocol literals")); + assert!(description.contains("file paths, commands, logs, identifiers")); + } + + #[test] + fn tool_descriptions_omit_ticket_record_language_guidance_when_unset() { + let temp = TempDir::new().unwrap(); + let description = tool_description_by_name(backend(&temp), "TicketComment"); + + assert!(!description.contains("Ticket record language:")); + assert!(!description.contains("worker.language")); + } + #[tokio::test] async fn ticket_tools_create_list_show_and_doctor() { let temp = TempDir::new().unwrap(); @@ -2256,7 +2303,6 @@ mod tests { let temp = TempDir::new().unwrap(); let create = tool(tool_definition::( "TicketCreate", - CREATE_DESCRIPTION, backend(&temp), )); let _ = create;