feat(ticket): align Web actions and instructions with intent-led work
This commit is contained in:
@@ -870,12 +870,14 @@ impl WorkerOperation {
|
||||
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
|
||||
}
|
||||
Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.",
|
||||
Self::Stop => "Stop a known Runtime Worker when allowed.",
|
||||
Self::Stop => {
|
||||
"Stop a known Runtime Worker when allowed. Stop is independent of Ticket conclusion: it does not end unfinished work, remove retained session records, release attachments, or delete Workdirs."
|
||||
}
|
||||
Self::Restore => {
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
|
||||
}
|
||||
Self::Remove => {
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Conclusion, stopping, retention/removal, attachment release, and Workdir deletion are separate decisions. Removal releases attachments but preserves Workdir materialization. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2050,6 +2052,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_descriptions_separate_unfinished_work_retention_and_attachment_cleanup() {
|
||||
let stop = worker_tool_declaration(WorkerOperation::Stop);
|
||||
assert!(stop.description.contains("does not end unfinished work"));
|
||||
assert!(
|
||||
stop.description
|
||||
.contains("release attachments, or delete Workdirs")
|
||||
);
|
||||
let remove = worker_tool_declaration(WorkerOperation::Remove);
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("Terminal Ticket responsibility is retained")
|
||||
);
|
||||
assert!(remove.description.contains("does not require unassignment"));
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("releases attachments but preserves Workdir materialization")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_remove_forwards_only_target_and_bounded_reason() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -27,8 +27,7 @@ use wip_protocol::{
|
||||
|
||||
pub const FEATURE_ID: &str = "merge_request";
|
||||
const FEATURE_NAME: &str = "Merge Request tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Operation-specific Merge Request workflow tools over Workspace authority.";
|
||||
const FEATURE_DESCRIPTION: &str = "Optional repository MR workflow with trusted review and separate Orchestrator integration authority; not a universal Ticket conclusion gate.";
|
||||
const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
|
||||
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
|
||||
|
||||
@@ -1085,10 +1084,10 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.",
|
||||
),
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request using current trusted exact-source approval and provider evidence. This records an already-applied integration, not a Ticket conclusion or assignment release; a review-not-required Ticket request does not bypass MR integration guards.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
"Submit the injected trusted Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not. The capability authenticates the attempt, not the verdict: independent natural-language judgment and validation remain the Reviewer's responsibility.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::feature::{
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -41,10 +42,7 @@ impl FeatureModule for OrchestrationFeature {
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
TOOL_NAME,
|
||||
"Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -81,8 +79,10 @@ struct SpawnTicketWorkerWorkdirInput {
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
runtime_id: String,
|
||||
/// Registered profile selector. Backend resolves the profile and validates claims.
|
||||
profile: String,
|
||||
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
|
||||
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
|
||||
#[serde(default)]
|
||||
profile: Option<String>,
|
||||
/// Initial user request delivered through the normal typed submission path.
|
||||
initial_request: String,
|
||||
/// Optional Flow selector, delivered as a typed Flow segment before the request.
|
||||
@@ -116,7 +116,11 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
||||
let profile = non_empty(input.profile, "profile")?;
|
||||
let profile = input
|
||||
.profile
|
||||
.map(|profile| non_empty(profile, "profile"))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| "builtin:ticket-worker".into());
|
||||
let initial_request = non_empty(input.initial_request, "initial_request")?;
|
||||
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
|
||||
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
||||
@@ -196,9 +200,7 @@ fn definition(
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let meta = ToolMeta::new(TOOL_NAME)
|
||||
.description(
|
||||
"Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.",
|
||||
)
|
||||
.description(TOOL_DESCRIPTION)
|
||||
.input_schema(schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketWorkerTool {
|
||||
ticket_service: ticket_service.clone(),
|
||||
@@ -389,6 +391,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
|
||||
service.clone(),
|
||||
);
|
||||
let (meta, tool) = definition();
|
||||
let required = meta.input_schema["required"].as_array().unwrap();
|
||||
assert!(!required.iter().any(|field| field == "profile"));
|
||||
assert!(
|
||||
meta.description
|
||||
.contains("neither trusted review nor merge authority")
|
||||
);
|
||||
for profile in [None, Some(serde_json::Value::Null)] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove("profile");
|
||||
if let Some(profile) = profile {
|
||||
input["profile"] = profile;
|
||||
}
|
||||
input["initial_request"] = serde_json::json!(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
);
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-default", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for request in service.requests.lock().unwrap().iter() {
|
||||
assert_eq!(request.profile, "builtin:ticket-worker");
|
||||
assert!(request.workdir_attachments.is_empty());
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![Segment::text(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||
let service = Arc::new(RecordingService {
|
||||
@@ -519,7 +563,7 @@ mod tests {
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
let error = tool
|
||||
@@ -567,13 +611,13 @@ mod tests {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap();
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
assert!(
|
||||
required.contains(&serde_json::json!(field)),
|
||||
"missing required field {field}"
|
||||
);
|
||||
}
|
||||
for field in ["flow", "workdir_attachments"] {
|
||||
for field in ["profile", "flow", "workdir_attachments"] {
|
||||
assert!(schema["properties"].get(field).is_some());
|
||||
assert!(!required.contains(&serde_json::json!(field)));
|
||||
}
|
||||
|
||||
@@ -335,9 +335,9 @@ struct WorkspaceTicketCloseInput {
|
||||
references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Close a Ticket with resolution, item/state CAS, and replay key. No Merge Request or enforced state sequence is required. Actor identity is transport-bound; this does not integrate Merge Requests or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record a Ticket completion decision with reason, item/state CAS, and replay key. Merge Requests and approvals are not prerequisites. The Backend authorizes the assigned Worker or registered Workspace Orchestrator; roles supplied by the model never grant authority.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. This does not start Workers, queue dependencies, integrate Merge Requests, or enlarge live Workdir grants; the Backend validates actor authority.";
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketDecisionKind {
|
||||
@@ -508,8 +508,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Typed Ticket operations through the authoritative Workspace API.";
|
||||
const FEATURE_DESCRIPTION: &str = "Intent-led Ticket reads and authorized decisions through the Workspace API; no universal MR/approval gate, and no Backend judgment of natural-language satisfaction.";
|
||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
@@ -2945,6 +2944,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() {
|
||||
let tools = native_ticket_tools(
|
||||
decision_client(200, TicketWorkflowState::Ready),
|
||||
TicketFeatureAccess::work_report(),
|
||||
)
|
||||
.unwrap();
|
||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||
let complete = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|op| op.name == "complete")
|
||||
.unwrap();
|
||||
let summary = &complete.documentation.as_ref().unwrap().summary;
|
||||
assert!(summary.contains("Merge Requests and approvals are not prerequisites"));
|
||||
assert!(summary.contains("return without conclusion need not call this tool"));
|
||||
assert!(summary.contains("not real judgment"));
|
||||
assert!(summary.contains("retaining terminal responsibility"));
|
||||
assert!(summary.contains("does not grant review/merge authority or stop/remove Workers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
|
||||
let client = decision_client(200, TicketWorkflowState::Done);
|
||||
|
||||
@@ -1034,7 +1034,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("This policy governs naming only"));
|
||||
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
|
||||
assert!(rendered.contains("Worker cleanup is a separate lifecycle decision"));
|
||||
assert!(rendered.contains("not an automatic consequence of a Ticket state"));
|
||||
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
|
||||
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
|
||||
assert!(rendered.contains("call `WorkerStop`"));
|
||||
|
||||
@@ -322,6 +322,154 @@ fn append_trailing_section(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::feature::builtin::merge_request::MergeRequestFeature;
|
||||
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use crate::worker::TestWorkspaceHttpClient;
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
|
||||
// These builtin resources are the model-visible workflow compatibility contract.
|
||||
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
|
||||
fn ticket_workflow_fixture(
|
||||
role: &str,
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(
|
||||
client.clone(),
|
||||
TicketFeatureAccess {
|
||||
thread: true,
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_module(MergeRequestFeature::new(client, mr_config))
|
||||
.install_into_pending(&mut tools, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
let metas: Vec<_> = tools.into_iter().map(|definition| definition().0).collect();
|
||||
let instructions = report.installed_instruction_contributions();
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let scope = Scope::empty();
|
||||
let rendered = SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only())
|
||||
.unwrap()
|
||||
.render(&SystemPromptContext {
|
||||
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
cwd: Cow::Borrowed("."),
|
||||
language: "en",
|
||||
scope: &scope,
|
||||
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
||||
feature_instructions: &instructions,
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
prompts: &prompts,
|
||||
})
|
||||
.unwrap();
|
||||
(rendered, metas)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_review_not_required_without_mr_gate() {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture("role.ticket_worker", MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("No Merge Request or approval is a prerequisite"));
|
||||
assert!(prompt.contains("optional Ticket comment"));
|
||||
assert!(prompt.contains("optional Drive document/artifact"));
|
||||
assert!(!prompt.contains("## Merge Request workflow"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("Merge Requests and approvals are not prerequisites")
|
||||
);
|
||||
assert!(complete.description.contains("not real judgment"));
|
||||
assert!(
|
||||
complete.input_schema["properties"]
|
||||
.get("approval_event_id")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_code_instructions_preserve_independent_review_before_merge() {
|
||||
let (prompt, tools) = ticket_workflow_fixture(
|
||||
"role.orchestrator",
|
||||
MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(prompt.contains("Trusted Reviewer authority"));
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"Target integration and `CompleteMergeRequest` are Orchestrator authority"
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not bypass the guarded review requirements for MR integration")
|
||||
);
|
||||
assert!(prompt.contains("not a universal Ticket pipeline"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteMergeRequest")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("trusted exact-source approval")
|
||||
);
|
||||
assert!(
|
||||
complete.input_schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|field| field == "approval_event_id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_return_without_conclusion_and_separate_cleanup() {
|
||||
for role in ["role.ticket_worker", "role.coder", "role.orchestrator"] {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("return without conclusion"), "{role}");
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"a turn ending or a result report is not itself a completion decision"
|
||||
),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("stopping a Worker alone does not end unfinished work"),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not stop, remove, or clean up"),
|
||||
"{role}"
|
||||
);
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("return without conclusion need not call this tool")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
|
||||
|
||||
Reference in New Issue
Block a user