chore: merge develop preserving documentation structure and console UX

Integrate develop at 523bade3 while retaining concise agent guidance, rustdoc-owned design rationale, and current console history/rewind behavior. Adapt incoming tests to public entry-ID rewind and synchronize OpenAPI/TypeScript artifacts.

Validation: default-member cargo test --all-features (4936 passed, 23 ignored), root cargo check, all-feature rustdoc with broken links denied, format/diff/name guards, OpenAPI and 11 TypeScript freshness checks. Web: 613 unit, 327 component, 7 Ticket browser and 3 Console browser tests; check/build passed. Merged-state Ticket visual review at 1440/768/390px in light/dark; no matching pre-merge image baseline. E2E/live-provider checks not run.
This commit is contained in:
2026-10-13 02:46:50 +09:00
213 changed files with 59018 additions and 14443 deletions
+140 -9
View File
@@ -1,9 +1,22 @@
//! Summarization and reference-file selection by the compaction Internal Worker.
//!
//! Summary input is a bounded history projection; it does not rewrite the original history.
//! Ordinary history uses a bounded overview plus exploration, while the previous Host-produced
//! `CompactionSummary` is mandatory full-text input independent of overview and retention budgets.
//! The replacement supersedes that old summary; it is excluded from both ordinary overview and
//! retained tail. Other derived context remains subject to normal retention. Current tasks and
//! recent progress support continuity, but do not replace original goals or completion conditions.
//!
//! The full inherited summary still counts against the hard compactor input cap. Before provider
//! measurements exist, occupancy includes input, system prompt, and installed tools. The normal
//! Worker interceptor enforces this cap, including persisted budget warnings; overflow fails
//! without activating a replacement rather than clipping mandatory material. Provider failures
//! likewise leave the source active. Estimates are not exact model-token guarantees.
//!
//! Results collect in [`CompactWorkerContext`]; [`crate::Worker::compact`] validates, saves,
//! and activates them. Missing summaries or hard-limit violations fail compaction, unlike
//! targets or warning-only thresholds.
//! and activates them. Missing summaries and hard input/output limits fail compaction. An oversized
//! output gets a bounded rewrite attempt. The assembled replacement's result-context threshold is
//! advisory: a warning neither drops content nor bypasses subsequent request-time safety checks.
//! Deterministic transport/retention tests do not prove a model's semantic retention of user intent.
//!
//! Auto-read accepts only UTF-8 text through the current WorkdirSession, checking scope and
//! the aggregate budget. Images, binary data, and truncated content stay out of the body;
@@ -372,6 +385,7 @@ pub(crate) struct CompactWorkerInterceptor {
pub on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
warning_sent: AtomicBool,
last_remaining_tokens: AtomicU64,
request_shape: Option<(String, agen::tool_server::ToolServerHandle)>,
}
impl CompactWorkerInterceptor {
@@ -390,9 +404,21 @@ impl CompactWorkerInterceptor {
on_warning,
warning_sent: AtomicBool::new(false),
last_remaining_tokens: AtomicU64::new(max_input_tokens),
request_shape: None,
}
}
pub(crate) fn with_request_shape(
mut self,
system_prompt: String,
tools: agen::tool_server::ToolServerHandle,
) -> Self {
// Features install after engine configuration. This shared handle sees
// the final installed tool definitions at the actual request boundary.
self.request_shape = Some((system_prompt, tools));
self
}
fn maybe_emit_warning(&self, remaining: u64) -> Option<Item> {
let warning_threshold = self.finish_warning_remaining_tokens;
let reserve_threshold = self.final_reserve_tokens;
@@ -405,7 +431,12 @@ impl CompactWorkerInterceptor {
let message = format!(
"compact worker context budget is low ({remaining}/{} tokens remaining). \
Stop broad exploration now, read only if absolutely necessary, then call \
`write_summary` with the final structured summary.",
`write_summary` with the final structured summary. Preserve ongoing goals and \
user directives from the full initial summary and segment. Compress duplicate \
explanations and old work details first, not scope, prohibitions, completion \
conditions or exception-reporting duties. Apply explicit later changes/retractions \
and retain uncertainty; recovery procedures and recent progress do not replace \
the original request.",
self.max_input_tokens
);
if let Some(cb) = self.on_warning.as_ref() {
@@ -426,17 +457,38 @@ impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
let context = context.items;
let records = self.usage_tracker.records();
let estimate = agen::token_counter::total_tokens(context, &records);
if estimate.tokens > self.max_input_tokens {
// Before the first provider measurement, include the system and tool
// shape as well as *all* mandatory summary/input bytes. Later exact
// input measurements already include that shape; do not double charge.
let shape_tokens = if records.is_empty() {
self.request_shape.as_ref().map_or(0, |(system, tools)| {
let tool_bytes = serde_json::to_vec(&tools.tool_definitions_sorted())
.map(|bytes| bytes.len())
.unwrap_or(0);
estimate_tokens(system.len().saturating_add(tool_bytes))
})
} else {
0
};
let input_tokens = estimate.tokens.saturating_add(shape_tokens);
if input_tokens > self.max_input_tokens {
return Ok(PreRequestAction::Cancel(format!(
"compact worker input occupancy exceeded {} tokens",
"compact worker input occupancy (including mandatory full initial summary) exceeded {} tokens; no summary was truncated",
self.max_input_tokens
)));
}
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
let remaining = self.max_input_tokens.saturating_sub(input_tokens);
self.last_remaining_tokens
.store(remaining, Ordering::Release);
if let Some(item) = self.maybe_emit_warning(remaining) {
let warning_tokens =
agen::token_counter::total_tokens(std::slice::from_ref(&item), &[]).tokens;
if input_tokens.saturating_add(warning_tokens) > self.max_input_tokens {
return Ok(PreRequestAction::Cancel(
"compact worker input occupancy cannot fit budget warning alongside mandatory full input; no summary was truncated".into(),
));
}
self.usage_tracker.note_request(context.len() + 1);
return Ok(PreRequestAction::ContinueWith(vec![item]));
}
@@ -535,6 +587,85 @@ mod tests {
));
}
#[tokio::test]
async fn compact_worker_warning_policy_keeps_user_intent_and_fails_if_warning_cannot_fit() {
let interceptor =
CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 100, 100, 0, None);
let mut context = vec![Item::user_message("full input")];
let result = interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap();
assert!(
matches!(result, PreRequestAction::Cancel(message) if message.contains("no summary was truncated"))
);
let interceptor =
CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 1000, 1000, 0, None);
let result = interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap();
let PreRequestAction::ContinueWith(items) = result else {
panic!("expected persisted warning")
};
let warning = items[0].as_text().unwrap();
for policy in [
"full initial summary and segment",
"prohibitions",
"completion conditions",
"exception-reporting duties",
"changes/retractions",
"uncertainty",
"Compress duplicate",
] {
assert!(warning.contains(policy), "missing warning policy: {policy}");
}
}
#[tokio::test]
async fn compact_worker_initial_request_counts_system_shape_but_measurements_do_not_double_count()
{
let tracker = Arc::new(UsageTracker::new());
let interceptor = CompactWorkerInterceptor::new(tracker.clone(), 100, 0, 0, None)
.with_request_shape(
"system".repeat(100),
agen::tool_server::ToolServerHandle::default(),
);
let mut context = vec![Item::user_message("full input")];
assert!(matches!(
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Cancel(_)
));
tracker.note_request(context.len());
tracker.record_usage(&make_usage(50));
assert!(matches!(
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
}
#[tokio::test]
async fn compact_worker_interceptor_warns_before_hard_cap() {
let tracker = Arc::new(UsageTracker::new());
@@ -542,8 +673,8 @@ mod tests {
let captured = warnings.clone();
let interceptor = CompactWorkerInterceptor::new(
tracker.clone(),
150,
60,
500,
410,
20,
Some(Arc::new(move |message| {
captured.lock().unwrap().push(message);
@@ -574,6 +574,7 @@ where
attempt.instance_id, attempt.attempt_id
)),
max_turns: Some(12),
compaction_budget: None,
engine_configurator: None,
features,
required_tools: &[
@@ -473,6 +473,9 @@ pub struct WorkerLifecycleWorkdirAttachment {
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
/// Observed delegation required for Orchestrator Ticket starts.
pub orchestrator_assignment_id: Option<String>,
pub runtime_id: String,
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
@@ -494,6 +497,7 @@ fn workspace_worker_create_request(
let ticket_assignment = match (request.ticket_id, request.operation_id) {
(Some(ticket_id), Some(operation_id)) => {
Some(CreateWorkspaceWorkerTicketAssignmentRequest {
orchestrator_assignment_id: request.orchestrator_assignment_id,
ticket_id,
operation_id,
})
@@ -729,6 +733,9 @@ struct WorkerSpawnInput {
/// neither the profile nor an initial Flow segment grants review authority.
#[serde(default)]
ticket_id: Option<String>,
/// Observed current Orchestrator delegation, required for a Ticket start by the Orchestrator.
#[serde(default)]
orchestrator_assignment_id: Option<String>,
#[serde(default)]
display_name: Option<String>,
/// Normal typed initial user submission delivered after spawn. An empty
@@ -913,6 +920,10 @@ impl Tool for WorkspaceWorkerTool {
.transpose()?;
self.control
.spawn_worker(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input
.orchestrator_assignment_id
.map(|id| authority_id(&id, "orchestrator_assignment_id"))
.transpose()?,
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
@@ -1895,6 +1906,7 @@ mod tests {
#[test]
fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() {
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: None,
runtime_id: "runtime-1".to_string(),
workdir_attachments: vec![
WorkerLifecycleWorkdirAttachment {
@@ -1954,6 +1966,7 @@ mod tests {
#[test]
fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() {
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: Some("delegation-742".into()),
runtime_id: "runtime-1".to_string(),
workdir_attachments: Vec::new(),
profile: "project:ticket-worker".to_string(),
@@ -1971,6 +1984,7 @@ mod tests {
value["ticket_assignment"],
serde_json::json!({
"ticket_id": "T-719",
"orchestrator_assignment_id": "delegation-742",
"operation_id": "spawn-ticket-worker:T-719:call-1",
})
);
@@ -574,6 +574,7 @@ impl MemoryLifecycleTask {
.extraction
.worker_max_turns
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
compaction_budget: None,
engine_configurator: None,
features,
required_tools: &[
@@ -388,6 +388,7 @@ impl SubjektivSurfaceLifecycleTask {
input,
cache_key: None,
max_turns: Some(EDITOR_MAX_TURNS),
compaction_budget: None,
engine_configurator: None,
features,
required_tools: &["SubmitMemorySurface"],
@@ -21,7 +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.";
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. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. 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;
@@ -78,6 +78,9 @@ struct SpawnTicketWorkerWorkdirInput {
#[serde(deny_unknown_fields)]
struct SpawnTicketWorkerInput {
ticket_id: String,
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
#[serde(default)]
orchestrator_assignment_id: Option<String>,
runtime_id: String,
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
@@ -132,6 +135,10 @@ impl Tool for SpawnTicketWorkerTool {
let response = self
.worker_service
.spawn(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: input
.orchestrator_assignment_id
.map(|id| authority_id(id, "orchestrator_assignment_id"))
.transpose()?,
runtime_id,
workdir_attachments,
profile,
@@ -247,13 +254,13 @@ fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
mod tests {
use std::sync::Mutex;
use ticket::{TicketError, TicketWorkflowState};
use ticket::{TicketError, TicketState};
use super::*;
use crate::feature::builtin::ticket::TicketHandoff;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
struct FixedTicketService(TicketWorkflowState);
struct FixedTicketService(TicketState);
impl TicketService for FixedTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
@@ -295,7 +302,7 @@ mod tests {
})
}
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
fn tool(service: Arc<RecordingService>, state: TicketState) -> SpawnTicketWorkerTool {
SpawnTicketWorkerTool {
ticket_service: Arc::new(FixedTicketService(state)),
worker_service: service,
@@ -307,6 +314,7 @@ mod tests {
let service = Arc::new(RecordingService::default());
let mut input = input();
input["flow"] = serde_json::json!("project:investigation");
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
input["workdir_attachments"] = serde_json::json!([
{
"alias": "checkout",
@@ -315,7 +323,7 @@ mod tests {
},
{"alias": "docs", "working_directory_id": "workdir-2"}
]);
let output = tool(service.clone(), TicketWorkflowState::Queued)
let output = tool(service.clone(), TicketState::Open)
.execute(
&input.to_string(),
ToolExecutionContext::new("call-7", "batch-1", 0),
@@ -327,6 +335,10 @@ mod tests {
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.runtime_id, "runtime-1");
assert_eq!(
request.orchestrator_assignment_id.as_deref(),
Some("delegation-742")
);
assert_eq!(request.profile, "project:ticket-worker");
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
assert_eq!(
@@ -372,7 +384,7 @@ mod tests {
input["workdir_attachments"] = serde_json::json!([]);
input["flow"] = serde_json::Value::Null;
}
tool(service.clone(), TicketWorkflowState::Queued)
tool(service.clone(), TicketState::Open)
.execute(
&input.to_string(),
ToolExecutionContext::new("call-empty", "batch-1", 0),
@@ -395,7 +407,7 @@ mod tests {
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)),
Arc::new(FixedTicketService(TicketState::Open)),
service.clone(),
);
let (meta, tool) = definition();
@@ -434,33 +446,35 @@ mod tests {
}
#[tokio::test]
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
let service = Arc::new(RecordingService {
response: Some(WorkspaceResponse {
status: 409,
body: "Backend rejected Ticket claim".into(),
}),
..Default::default()
});
let error = tool(service.clone(), TicketWorkflowState::Planning)
.execute(
&input().to_string(),
ToolExecutionContext::new("call-claim", "batch-1", 0),
)
.await
.unwrap_err();
assert_eq!(service.requests.lock().unwrap().len(), 1);
assert!(
error
.to_string()
.contains("HTTP 409: Backend rejected Ticket claim")
);
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() {
for state in [TicketState::Open, TicketState::Closed] {
let service = Arc::new(RecordingService {
response: Some(WorkspaceResponse {
status: 409,
body: "Backend rejected Ticket claim".into(),
}),
..Default::default()
});
let error = tool(service.clone(), state)
.execute(
&input().to_string(),
ToolExecutionContext::new("call-claim", "batch-1", 0),
)
.await
.unwrap_err();
assert_eq!(service.requests.lock().unwrap().len(), 1);
assert!(
error
.to_string()
.contains("HTTP 409: Backend rejected Ticket claim")
);
}
}
#[tokio::test]
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
let service = Arc::new(RecordingService::default());
let tool = tool(service.clone(), TicketWorkflowState::Queued);
let tool = tool(service.clone(), TicketState::Open);
let cases = [
(
"profile",
@@ -473,6 +487,16 @@ mod tests {
"initial_request must not be empty",
),
("flow", serde_json::json!(" "), "flow must not be empty"),
(
"orchestrator_assignment_id",
serde_json::json!("http://invalid/id"),
"orchestrator_assignment_id must be an authority id",
),
(
"orchestrator_assignment_id",
serde_json::json!(" "),
"orchestrator_assignment_id must not be empty",
),
(
"runtime_id",
serde_json::json!("runtime/1"),
@@ -603,7 +627,7 @@ mod tests {
#[test]
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
let definition = definition(
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
Arc::new(FixedTicketService(TicketState::Open)),
Arc::new(RecordingService::default()),
);
let (meta, _) = definition();
@@ -13,7 +13,12 @@ pub(super) struct ModelTicketQueryResponse {
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
assignment_tracking: Vec<ModelAssignmentTracking>,
tags: Vec<ticket::WorkspaceTag>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
@@ -38,11 +43,17 @@ pub(super) struct ModelTicketDetail {
content_digest: String,
title: String,
body: String,
state: String,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
assignment_tracking: Vec<ModelAssignmentTracking>,
tags: Vec<ticket::WorkspaceTag>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
closure_history: Vec<ticket::TicketCaseHistoryEntry>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
@@ -89,13 +100,36 @@ pub(super) struct ModelObjectiveDetail {
event_page: ModelObjectiveEventPage,
}
#[derive(Debug, Serialize)]
struct ModelAssignmentTracking {
assignment_id: String,
role: String,
principal: ticket::TicketAssignmentPrincipalSnapshot,
assigned_by: String,
assigned_at: String,
release: Option<ticket::TicketAssignmentRelease>,
current: bool,
tracking: Option<ticket::TicketAssignmentTracking>,
last_event_id: Option<String>,
updated_at: Option<String>,
migration_diagnostic: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelWorkerSummary {
worker: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketTagChange {
before: Vec<ticket::WorkspaceTag>,
after: Vec<ticket::WorkspaceTag>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvent {
#[serde(skip_serializing_if = "Option::is_none")]
tag_changes: Option<ModelTicketTagChange>,
sequence: usize,
kind: String,
body: Option<String>,
@@ -216,6 +250,16 @@ struct ModelObjectiveEventPage {
has_more: bool,
}
fn typed_field<T: serde::de::DeserializeOwned>(
root: &Map<String, Value>,
key: &str,
) -> Result<T, String> {
let value = root
.get(key)
.ok_or_else(|| format!("Ticket projection requires {key}"))?;
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
let root = object(&value, "Ticket query response")?;
let page = object_field(root, "page")?;
@@ -235,7 +279,12 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
Ok(ModelTicketQueryItem {
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
state: typed_field(item, "state")?,
closure_operation_key: typed_field(item, "closure_operation_key")?,
current_closure: typed_field(item, "current_closure")?,
legacy_handoff: typed_field(item, "legacy_handoff")?,
assignment_tracking: project_assignment_tracking(item)?,
tags: project_tags(item)?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
@@ -286,7 +335,13 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?,
current_closure: typed_field(root, "current_closure")?,
legacy_handoff: typed_field(root, "legacy_handoff")?,
closure_history: typed_field(root, "closure_history")?,
assignment_tracking: project_assignment_tracking(root)?,
tags: project_tags(root)?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
@@ -394,7 +449,27 @@ fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
let event = object(value, "Ticket event")?;
let tag_changes = if event.get("kind").and_then(Value::as_str) == Some("tags_changed") {
let attributes = event
.get("attributes")
.ok_or("missing tag audit attributes")?;
let snapshot = |field| -> Result<Vec<ticket::WorkspaceTag>, String> {
let encoded = attributes
.get(field)
.and_then(Value::as_str)
.ok_or("missing tag audit snapshot")?;
serde_json::from_str(encoded)
.map_err(|error| format!("invalid tag audit snapshot: {error}"))
};
Some(ModelTicketTagChange {
before: snapshot("tags_before")?,
after: snapshot("tags_after")?,
})
} else {
None
};
Ok(ModelTicketEvent {
tag_changes,
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
body: match event.get("body") {
@@ -616,6 +691,54 @@ fn object_field<'a>(
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn project_assignment_tracking(
object: &Map<String, Value>,
) -> Result<Vec<ModelAssignmentTracking>, String> {
let values = array_field(object, "assignment_tracking")?;
if values.len() > 1 {
return Err("Ticket has more than one current Orchestrator delegation".into());
}
values
.iter()
.map(|value| {
let projection: ticket::TicketAssignmentTrackingProjection =
serde_json::from_value(value.clone())
.map_err(|error| format!("invalid assignment tracking: {error}"))?;
Ok(ModelAssignmentTracking {
assignment_id: projection.assignment_id,
role: projection.role,
principal: projection.principal,
assigned_by: projection.assigned_by,
assigned_at: projection.assigned_at,
release: projection.release,
current: projection.current,
tracking: projection.tracking,
last_event_id: projection.last_event_id,
updated_at: projection.updated_at,
migration_diagnostic: projection.migration_diagnostic,
})
})
.collect()
}
fn project_tags(object: &Map<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> {
let values = array_field(object, "tags")?;
if values.len() > 100 {
return Err("Ticket projection exceeds 100 tags".into());
}
values
.iter()
.map(|value| {
let tag: ticket::WorkspaceTag = serde_json::from_value(value.clone())
.map_err(|error| format!("invalid Workspace tag: {error}"))?;
if tag.deleted {
return Err("current Ticket projection contains a deleted tag".into());
}
Ok(tag)
})
.collect()
}
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
object
.get(key)
@@ -694,16 +817,10 @@ mod tests {
#[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
@@ -724,6 +841,57 @@ mod tests {
}
}
#[test]
fn reopened_ticket_projection_preserves_closure_observation_history_and_legacy_handoff() {
let history = json!([
{
"operation_key": "close-1", "previous_closure_operation_key": null,
"action": {"action": "close", "reason": "not_planned"},
"explanation": "Not proceeding", "references": [{"kind": "ticket", "target": "T-12"}],
"author": "workspace-user:alice", "at": "2026-01-01T00:00:00Z"
},
{
"operation_key": "reopen-1", "previous_closure_operation_key": "close-1",
"action": {"action": "reopen"}, "explanation": "New information",
"references": [], "author": "workspace-user:bob", "at": "2026-01-02T00:00:00Z"
}
]);
let legacy = json!({
"workflow_state": "queued", "workflow_state_explicit": true, "status": "open",
"queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null
});
let response = json!({
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements",
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
"closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null
});
let projected =
serde_json::to_value(project_ticket_detail(response.clone()).unwrap()).unwrap();
assert_eq!(projected["state"], "open");
assert_eq!(projected["closure_operation_key"], "close-1");
assert_eq!(projected["current_closure"], Value::Null);
assert_eq!(projected["closure_history"], history);
assert_eq!(projected["legacy_handoff"], legacy);
assert_eq!(projected["tags"], response["tags"]);
assert_eq!(projected["content_digest"], "content-digest");
// Missing observations must not silently become null: the model must be
// able to distinguish an observed never-closed case from unknown data.
let mut missing = response;
missing
.as_object_mut()
.unwrap()
.remove("closure_operation_key");
assert!(
project_ticket_detail(missing)
.unwrap_err()
.contains("closure_operation_key")
);
}
#[test]
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
@@ -738,9 +906,9 @@ mod tests {
"linked_tickets": ["00001M0E82D1V"],
"linked_ticket_summaries": [{
"id": "00001M0E82D1V",
"resource_key": "T-496",
"resource_key": "T-496", "assignment_tracking": [], "tags": [],
"title": "Ticket",
"state": "done",
"state": "closed",
"updated_at": "2026-01-02T00:00:00Z"
}],
"events": [{
@@ -769,9 +937,10 @@ mod tests {
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"resource_key": "T-543", "assignment_tracking": [], "tags": [],
"title": "Ticket",
"state": "inprogress",
"state": "open",
"closure_operation_key": null, "current_closure": null, "legacy_handoff": null,
"readiness": null,
"priority": "high",
"created_at": null,
@@ -840,11 +1009,12 @@ mod tests {
fn ticket_detail_projection_accepts_current_blocker_shape() {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"resource_key": "T-588", "assignment_tracking": [], "tags": [],
"content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Queued Submit",
"body": "Body",
"state": "planning",
"state": "open",
"readiness": null,
"priority": "P2",
"created_at": "2026-09-03T00:00:00Z",
@@ -859,7 +1029,7 @@ mod tests {
"reason_kind": "depends_on",
"relation_kind": "depends_on",
"note": "required foundation",
"blocking_state": "planning"
"blocking_state": "open"
}],
"notices": []
},
@@ -898,7 +1068,7 @@ mod tests {
json!([{
"ticket": "T-584",
"kind": "depends_on",
"state": "planning"
"state": "open"
}])
);
assert!(!projected.to_string().contains("internal-blocker"));
@@ -943,11 +1113,12 @@ mod tests {
"review_excerpt": "Reviewed source"
});
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"resource_key": "T-716", "assignment_tracking": [], "tags": [],
"content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
"state": "open",
"events": [],
"linked_objectives": [],
"assignments": [],
@@ -1123,4 +1294,132 @@ mod tests {
.expect_err("missing W-key must fail");
assert!(error.contains("W-"));
}
#[test]
fn ticket_query_and_detail_preserve_case_classification_and_delegation_independently() {
let tracking = json!({"workspace_id":"ws", "ticket_id":"internal-id", "assignment_id":"delegation", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"workspace-user:alice", "assigned_at":"2026-01-01T00:00:00Z", "release":null, "current":true, "last_event_id":"reported", "updated_at":"2026-01-01T00:00:00Z", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"Human answer", "progress":null, "next_action":"Compare the response", "result":null}});
let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false});
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
let detail = json!({
"resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1",
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
});
let mut item = detail.clone();
item["matched_fields"] = json!([]);
item["linked_objective_keys"] = json!([]);
for field in [
"relation_count",
"blocker_count",
"unresolved_blocker_count",
"unresolved_review_count",
] {
item[field] = json!(0);
}
let projected_detail =
serde_json::to_value(project_ticket_detail(detail.clone()).unwrap()).unwrap();
let projected_query = serde_json::to_value(
project_ticket_query(
json!({"items":[item.clone()], "page":{"has_more":false, "next_cursor":null}}),
)
.unwrap(),
)
.unwrap();
for field in [
"tags",
"state",
"closure_operation_key",
"current_closure",
"legacy_handoff",
] {
assert_eq!(projected_detail[field], detail[field], "detail {field}");
assert_eq!(
projected_query["tickets"][0][field], detail[field],
"query {field}"
);
}
for projected in [&projected_detail, &projected_query["tickets"][0]] {
let actual = &projected["assignment_tracking"][0];
assert_eq!(actual["assignment_id"], "delegation");
assert_eq!(actual["last_event_id"], "reported");
assert_eq!(actual["tracking"], tracking["tracking"]);
assert!(actual.get("workspace_id").is_none());
assert!(actual.get("ticket_id").is_none());
}
assert_eq!(projected_detail["closure_history"], history);
assert_eq!(projected_detail["content_digest"], "content-1");
// Unknown observations must not be replaced with empty/null values.
for field in [
"tags",
"closure_operation_key",
"current_closure",
"legacy_handoff",
"state",
] {
let mut missing_detail = detail.clone();
missing_detail.as_object_mut().unwrap().remove(field);
assert!(
project_ticket_detail(missing_detail)
.unwrap_err()
.contains(field),
"detail {field}"
);
let mut missing_item = item.clone();
missing_item.as_object_mut().unwrap().remove(field);
assert!(
project_ticket_query(
json!({"items":[missing_item], "page":{"has_more":false, "next_cursor":null}})
)
.unwrap_err()
.contains(field),
"query {field}"
);
}
for invalid_tags in [
json!([{"id":"tag-1"}]),
json!(vec![detail["tags"][0].clone(); 101]),
{
let mut deleted = detail["tags"].clone();
deleted[0]["deleted"] = json!(true);
deleted
},
] {
let mut malformed = detail.clone();
malformed["tags"] = invalid_tags;
assert!(project_ticket_detail(malformed).is_err());
}
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"unknown",
] {
let mut malformed = detail.clone();
malformed["state"] = json!(state);
assert!(
project_ticket_detail(malformed)
.unwrap_err()
.contains("state"),
"{state}"
);
}
}
#[test]
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}});
let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap();
assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag]));
assert_eq!(projected["tag_changes"]["after"], serde_json::json!([]));
let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null});
assert!(
serde_json::to_value(project_ticket_event(&ordinary).unwrap())
.unwrap()
.get("tag_changes")
.is_none()
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
//! Public delegation operations over shared host-bound typed Workspace forwarding.
//! Source/actor authorization and assignment/receipt authority remain Backend-owned.
//!
//! Assign targets responsibility, not Worker execution: the target principal is not the actor.
//! The nullable expected assignment must be explicitly observed, never omitted as unknown.
//! A new reissue creates a delegation; an exact retry retains its operation identity and full input.
//! Saved delegation plus notification failure is not failed Assign. Reread and reconcile stale or
//! unknown outcomes rather than silently generating a new operation key or using another transport.
//!
//! Answers continue waiting/inprogress against the same assignment/event CAS without reporting
//! progress; done needs explicit reissue. Retained assignments and sanitized diagnostics are reads,
//! not mutation authority. Delivery/start cursors are independent of handling history; Runtime
//! acceptance, Worker liveness, and the absence of diagnostics never attest model processing.
#[cfg(test)]
mod tests;
use super::*;
use crate::worker::WorkspaceServerOperation;
pub(super) const TOOL_NAMES: &[&str] = &[
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
pub(super) const READ_TOOL_NAMES: &[&str] =
&["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"];
#[derive(Clone, Copy)]
pub(super) enum PublicAssignmentKind {
Assign,
Clear,
Assignments,
Diagnostics,
Answer,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AssignInput {
ticket: String,
/// Target principal, not the authenticated actor. Backend validates role/principal pairing.
principal: server_api::TicketAssignmentPrincipal,
/// Observed current assignment ID, or explicitly null for an unassigned Ticket.
#[serde(deserialize_with = "observed_assignment")]
expected_assignment_id: Option<String>,
/// Stable identity of this Assign/reissue; exact retries keep the same full input.
operation_id: String,
}
fn observed_assignment<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(d)
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ClearInput {
ticket: String,
/// Observed exact current delegation to release, not Worker identity/status.
assignment_id: String,
operation_id: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AssignmentsInput {
ticket: String,
#[serde(flatten)]
query: server_api::TicketAssignmentsQuery,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DiagnosticsInput {
ticket: String,
#[serde(flatten)]
query: server_api::TicketAssignmentDiagnosticsQuery,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AnswerInput {
ticket: String,
#[serde(flatten)]
request: server_api::TicketAssignmentAnswerRequest,
}
impl PublicAssignmentKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::Assign => TOOL_NAMES[0],
Self::Clear => TOOL_NAMES[1],
Self::Assignments => TOOL_NAMES[2],
Self::Diagnostics => TOOL_NAMES[3],
Self::Answer => TOOL_NAMES[4],
}
}
fn schema(self) -> Value {
match self {
Self::Assign => {
let mut schema = json!(schemars::schema_for!(AssignInput));
let required = schema["required"]
.as_array_mut()
.expect("Assign object schema");
if !required
.iter()
.any(|field| field == "expected_assignment_id")
{
required.push(json!("expected_assignment_id"));
}
schema
}
Self::Clear => json!(schemars::schema_for!(ClearInput)),
Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)),
Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)),
Self::Answer => json!(schemars::schema_for!(AnswerInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
match name {
"TicketOrchestratorAssign" => {
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
}
"TicketOrchestratorClear" => {
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
}
"TicketAssignmentsQuery" => {
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
}
"TicketAssignmentDiagnostics" => {
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
}
"TicketAssignmentAnswer" => {
"Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start."
}
_ => unreachable!("unknown public assignment tool"),
}
}
fn identity(name: &str, value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument(format!(
"{name} must be nonempty, control-free and at most 1024 bytes"
)));
}
Ok(())
}
fn page(limit: Option<usize>) -> Result<usize, ToolError> {
let limit = limit.unwrap_or(100);
if !(1..=100).contains(&limit) {
return Err(ToolError::InvalidArgument(
"limit must be between 1 and 100".into(),
));
}
Ok(limit)
}
fn optional_identity(name: &str, value: &Option<String>) -> Result<(), ToolError> {
value
.as_deref()
.map(|value| identity(name, value))
.transpose()
.map(|_| ())
}
struct PublicAssignmentTool {
client: Arc<dyn WorkspaceClient>,
kind: PublicAssignmentKind,
}
#[async_trait]
impl Tool for PublicAssignmentTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Assignment tools require Backend Workspace identity; no local fallback".into(),
)
})?;
let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
let operation = match self.kind {
PublicAssignmentKind::Assign => {
let input: AssignInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("operation_id", &input.operation_id)?;
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
match &input.principal {
server_api::TicketAssignmentPrincipal::User { account_id } => {
identity("account_id", account_id)?
}
server_api::TicketAssignmentPrincipal::Worker {
runtime_id,
worker_id,
} => {
identity("runtime_id", runtime_id)?;
identity("worker_id", worker_id)?;
}
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
identity("agent_key", agent_key)?
}
}
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id: input.ticket,
role: server_api::TicketAssignmentRole::Orchestrator,
request: server_api::SetTicketRoleAssignmentRequest {
orchestrator_assignment_id: None,
operation_id: input.operation_id,
principal: input.principal,
expected_assignment_id: input.expected_assignment_id,
workdir_bindings: vec![],
},
}
}
PublicAssignmentKind::Clear => {
let input: ClearInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("assignment_id", &input.assignment_id)?;
identity("operation_id", &input.operation_id)?;
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id: input.ticket,
role: server_api::TicketAssignmentRole::Orchestrator,
query: server_api::ClearTicketRoleAssignmentQuery {
operation_id: Some(input.operation_id),
assignment_id: Some(input.assignment_id),
},
}
}
PublicAssignmentKind::Assignments => {
let mut input: AssignmentsInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
optional_identity("after_assignment_id", &input.query.after_assignment_id)?;
input.query.limit = Some(page(input.query.limit)?);
WorkspaceServerOperation::TicketAssignmentsPage {
ticket_id: input.ticket,
query: input.query,
}
}
PublicAssignmentKind::Diagnostics => {
let mut input: DiagnosticsInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("assignment_id", &input.query.assignment_id)?;
optional_identity("after_input_event_id", &input.query.after_input_event_id)?;
optional_identity(
"after_start_operation_id",
&input.query.after_start_operation_id,
)?;
input.query.limit = Some(page(input.query.limit)?);
WorkspaceServerOperation::TicketAssignmentDiagnostics {
ticket_id: input.ticket,
query: input.query,
}
}
PublicAssignmentKind::Answer => {
let input: AnswerInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
for (name, value) in [
("assignment_id", &input.request.0.assignment_id),
(
"expected_last_event_id",
&input.request.0.expected_last_event_id,
),
("operation_key", &input.request.0.operation_key),
] {
identity(name, value)?;
}
let body = &input.request.0.body;
if body.trim().is_empty() || body.len() > 16384 || body.contains('\0') {
return Err(ToolError::InvalidArgument(
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
));
}
WorkspaceServerOperation::TicketAssignmentAnswer {
ticket_id: input.ticket,
request: input.request,
}
}
};
let response = self
.client
.execute_server_operation(operation)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Assignment request failed with HTTP status {}: {}",
response.status,
response.body.chars().take(1024).collect::<String>()
)));
}
if response.body.len() > 1024 * 1024 {
return Err(ToolError::ExecutionFailed(
"Assignment response exceeds 1 MiB; request a smaller page".into(),
));
}
// Decode the actual public DTO and serialize only its declared fields; no raw
// backend payload/credentials or alternative result authority reaches the model.
let decode = |error: serde_json::Error| {
ToolError::ExecutionFailed(format!(
"Invalid Backend assignment response (outcome unknown after dispatch): {error}"
))
};
let content = match self.kind {
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear => serde_json::to_value(
serde_json::from_str::<server_api::TicketRoleAssignmentMutationResponse>(
&response.body,
)
.map_err(decode)?,
),
PublicAssignmentKind::Assignments => {
let result: server_api::TicketAssignmentsPage =
serde_json::from_str(&response.body).map_err(decode)?;
if result.0.items.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Assignment page exceeds 100 items".into(),
));
}
serde_json::to_value(result)
}
PublicAssignmentKind::Diagnostics => {
let result: server_api::TicketAssignmentDiagnosticsPage =
serde_json::from_str(&response.body).map_err(decode)?;
if result.inputs.len() > 100 || result.starts.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Diagnostic page exceeds 100 items".into(),
));
}
serde_json::to_value(result)
}
PublicAssignmentKind::Answer => serde_json::to_value(
serde_json::from_str::<server_api::TicketAssignmentAnswerResponse>(&response.body)
.map_err(decode)?,
),
}
.map_err(|error| ToolError::Internal(error.to_string()))?;
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(content.to_string()),
attachments: vec![],
})
}
}
pub(super) fn definition(
client: Arc<dyn WorkspaceClient>,
kind: PublicAssignmentKind,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(PublicAssignmentTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,598 @@
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
#[derive(Debug)]
struct Transport {
requests: Mutex<Vec<WorkspaceRequest>>,
response: Result<WorkspaceResponse, String>,
available: bool,
}
impl WorkspaceClient for Transport {
fn workspace_id(&self) -> Option<&str> {
self.available.then_some("workspace")
}
fn kind(&self) -> &str {
"assignment-test"
}
fn is_available(&self) -> bool {
self.available
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
self.response.clone().map_err(WorkspaceClientError::Request)
}
}
fn transport(status: u16, body: Value) -> Arc<Transport> {
Arc::new(Transport {
requests: Mutex::new(vec![]),
available: true,
response: Ok(WorkspaceResponse {
status,
body: body.to_string(),
}),
})
}
fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
vec![
(
PublicAssignmentKind::Assign,
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
PublicAssignmentKind::Clear,
json!({"ticket":"T-743", "assignment_id":"delegation", "operation_id":"clear-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
PublicAssignmentKind::Assignments,
json!({"ticket":"T-743", "after_assignment_id":"old/delegation?", "limit":10}),
json!({"items":[], "next_assignment_id":null}),
),
(
PublicAssignmentKind::Diagnostics,
json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}),
json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}),
),
(
PublicAssignmentKind::Answer,
json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}),
json!({"assignment_id":"delegation", "input_event_id":"ticket-event:8", "event_index":8}),
),
]
}
#[tokio::test]
async fn public_assignment_schemas_require_observations_and_exclude_model_actor_claims() {
for (kind, input, response) in cases() {
let client = transport(200, response);
let (meta, tool) = definition(client.clone(), kind)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(
schema.is_valid(&input),
"{}: {}",
meta.name,
meta.input_schema
);
for field in meta.input_schema["required"]
.as_array()
.into_iter()
.flatten()
{
let mut missing = input.clone();
missing
.as_object_mut()
.unwrap()
.remove(field.as_str().unwrap());
assert!(!schema.is_valid(&missing), "{} missing {field}", meta.name);
assert!(matches!(
tool.execute(&missing.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for actor_claim in [
"actor",
"author",
"role",
"runtime_id",
"worker_id",
"authentication_binding_id",
] {
let mut forged = input.clone();
forged[actor_claim] = json!("authority");
assert!(
!schema.is_valid(&forged),
"{} accepts {actor_claim}",
meta.name
);
assert!(matches!(
tool.execute(&forged.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
matches!(
kind,
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear
)
}) {
if matches!(kind, PublicAssignmentKind::Assign) {
input["expected_assignment_id"] = json!("previous-done");
}
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for _ in 0..2 {
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0], requests[1]);
assert!(
requests[0]
.path
.starts_with("/api/w/workspace/tickets/T-743/assignments/orchestrator")
);
match kind {
PublicAssignmentKind::Assign => {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Put);
let body: Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["expected_assignment_id"], "previous-done");
assert_eq!(body["operation_id"], input["operation_id"]);
assert_eq!(body["principal"], input["principal"]);
assert!(body.get("actor").is_none());
assert!(body.get("orchestrator_assignment_id").is_none());
assert_eq!(body["workdir_bindings"], json!([]));
}
PublicAssignmentKind::Clear => {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete);
assert!(requests[0].path.contains("assignment_id=delegation"));
assert!(requests[0].path.contains("operation_id=clear-1"));
assert!(requests[0].body.is_none());
}
_ => unreachable!(),
}
}
}
#[tokio::test]
async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() {
let (kind, input, response) = cases().pop().unwrap();
let client = transport(200, response.clone());
let (_, tool) = definition(client.clone(), kind)();
for _ in 0..2 {
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap(),
response
);
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests[0], requests[1]);
assert_eq!(
requests[0].path,
"/workspaces/workspace/tickets/T-743/assignment-answer"
);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
let mut expected = input;
expected.as_object_mut().unwrap().remove("ticket");
assert_eq!(body, expected);
assert!(body.get("tracking").is_none());
}
#[tokio::test]
async fn assignment_pages_forward_independent_encoded_cursors_and_reject_unbounded_input() {
for (kind, input, response) in cases().into_iter().filter(|(kind, _, _)| {
matches!(
kind,
PublicAssignmentKind::Assignments | PublicAssignmentKind::Diagnostics
)
}) {
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for limit in [0, 101] {
let mut invalid = input.clone();
invalid["limit"] = json!(limit);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert!(requests[0].path.contains("limit=10"));
match kind {
PublicAssignmentKind::Assignments => assert!(
requests[0]
.path
.contains("after_assignment_id=old%2Fdelegation%3F")
),
PublicAssignmentKind::Diagnostics => {
assert!(
requests[0]
.path
.contains("after_input_event_id=ticket-event%3A7")
);
assert!(
requests[0]
.path
.contains("after_start_operation_id=start%2F7")
);
}
_ => unreachable!(),
}
}
}
#[tokio::test]
async fn public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity() {
for (kind, input, _) in cases() {
for response in [
Ok(WorkspaceResponse {
status: 403,
body: "permission denied".into(),
}),
Ok(WorkspaceResponse {
status: 409,
body: "stale assignment or operation fingerprint mismatch".into(),
}),
Ok(WorkspaceResponse {
status: 200,
body: "invalid response after dispatch".into(),
}),
Err("response lost; outcome unknown".into()),
] {
let client = Arc::new(Transport {
requests: Mutex::new(vec![]),
available: true,
response,
});
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(
client.requests.lock().unwrap().len(),
1,
"{} retried",
kind.name()
);
}
let client = Arc::new(Transport {
requests: Mutex::new(vec![]),
available: false,
response: Err("unavailable".into()),
});
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("Backend Workspace identity")
);
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn diagnostics_strip_undeclared_content_and_preserve_unknown_processing() {
let (kind, input, mut response) = cases().remove(3);
response["rendered_content"] = json!("must not reach model");
response["authentication_binding_id"] = json!("must not reach model");
response["inputs"] = json!([{"input_event_id":"ticket-event:7", "delivery_id":null, "notification":"not_reserved", "acceptance_outcome_unknown":true, "rendered_content":"hidden"}]);
response["starts"] = json!([{"operation_id":"start", "claim_state":"reserved", "assignment_id":null, "runtime_id":null, "worker_id":null, "actual_processing_unknown":true, "authentication_binding_id":"hidden"}]);
let (_, tool) = definition(transport(200, response), kind)();
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert!(output.get("rendered_content").is_none());
assert!(output.get("authentication_binding_id").is_none());
assert_eq!(output["inputs"][0]["acceptance_outcome_unknown"], true);
assert!(output["inputs"][0].get("rendered_content").is_none());
assert_eq!(output["starts"][0]["actual_processing_unknown"], true);
assert!(
output["starts"][0]
.get("authentication_binding_id")
.is_none()
);
}
#[test]
fn public_assignment_normal_and_wip_access_excludes_mutations_for_read_only_and_review() {
for access in [
TicketFeatureAccess::read_only(),
TicketFeatureAccess::review(),
] {
let client = transport(200, json!({}));
let normal = enabled_ticket_definitions(client.clone(), access);
let native = native_ticket_tools(client, access).unwrap();
for name in READ_TOOL_NAMES {
assert!(normal.iter().any(|d| d().0.name == *name));
assert!(native.iter().any(|t| t.name == *name));
}
for name in [
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentAnswer",
] {
assert!(!normal.iter().any(|d| d().0.name == name));
assert!(!native.iter().any(|t| t.name == name));
}
}
for name in TOOL_NAMES {
let native = native_ticket_operation(name).unwrap();
assert_eq!(native.identity_field, Some("ticket"));
assert_eq!(native.mutating, !READ_TOOL_NAMES.contains(name));
}
}
#[tokio::test]
async fn ticket_query_closure_reason_is_typed_and_not_a_classification_tag() {
let client = transport(
200,
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"states":["closed"],"closure_reason":"completed","tag_ids":["tag-bug"]});
assert!(schema.is_valid(&input));
let invalid = json!({"closure_reason":"bug"});
assert!(!schema.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["closure_reason"], "completed");
assert_eq!(body["tag_ids"], json!(["tag-bug"]));
}
#[tokio::test]
async fn ticket_query_forwards_current_principal_and_self_reported_handling_filters() {
let client = transport(
200,
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"handling_status":"waiting","states":["open"],"limit":5});
assert!(schema.is_valid(&input));
for status in ["running", "idle", "stopped", "closed"] {
let mut invalid = input.clone();
invalid["handling_status"] = json!(status);
assert!(!schema.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["assigned_principal"], input["assigned_principal"]);
assert_eq!(body["handling_status"], "waiting");
assert_eq!(body["states"], json!(["open"]));
}
#[test]
fn resolved_builtin_profiles_expose_assignment_capabilities_without_repo_or_mr_prerequisites() {
let dir = tempfile::tempdir().unwrap();
for profile in [
"intake",
"orchestrator",
"coder",
"ticket-worker",
"reviewer",
] {
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(dir.path())
.resolve(
&manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")),
manifest::ProfileResolveOptions::with_worker_name("assignment-fixture"),
)
.unwrap();
let config = resolved.manifest.feature.ticket;
assert!(config.enabled, "{profile}");
let delegates = config.authoring || config.workflow;
let answers = delegates || config.thread;
let reports = config.workflow;
let access = TicketFeatureAccess {
authoring: config.authoring,
thread: config.thread,
intake: config.intake,
workflow: config.workflow,
};
let names = access.tool_names();
assert_eq!(
names.contains(&"TicketOrchestratorAssign"),
delegates,
"{profile}"
);
assert_eq!(
names.contains(&"TicketOrchestratorClear"),
delegates,
"{profile}"
);
assert_eq!(
names.contains(&"TicketAssignmentAnswer"),
answers,
"{profile}"
);
assert_eq!(
names.contains(&"TicketAssignmentTrackingUpdate"),
reports,
"{profile}"
);
assert!(
names.contains(&"TicketAssignmentsQuery")
&& names.contains(&"TicketAssignmentDiagnostics"),
"{profile}"
);
assert!(
!names.iter().any(|name| matches!(
*name,
"TicketQueue" | "TicketMarkReady" | "TicketIntakeReady"
)),
"{profile}"
);
}
}
#[tokio::test]
async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() {
let (kind, input, response) = cases().pop().unwrap();
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for body in [
String::new(),
" ".into(),
"x".repeat(16385),
"text\0hidden".into(),
] {
let mut invalid = input.clone();
invalid["body"] = json!(body);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for field in ["assignment_id", "expected_last_event_id", "operation_key"] {
for value in [String::new(), "x\n".into(), "x".repeat(1025)] {
let mut invalid = input.clone();
invalid[field] = json!(value);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() {
for (kind, mut input, response) in cases() {
let client = transport(200, response);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
input.as_object_mut().unwrap().remove("ticket");
let args: BTreeMap<_, _> = input
.as_object()
.unwrap()
.iter()
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
.collect();
let operation = native_ticket_operation(kind.name()).unwrap().operation;
handler
.call(
operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP {operation} rejected"));
assert_eq!(client.requests.lock().unwrap().len(), 1);
let mut forged = args.clone();
forged.insert("ticket".into(), WipValue::String("T-other".into()));
assert!(
handler
.call(
operation,
&forged,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
let (_, input, response) = cases().pop().unwrap();
let client = transport(200, response);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let denied = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: Some(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: vec![],
}),
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = input
.as_object()
.unwrap()
.iter()
.filter(|(k, _)| k.as_str() != "ticket")
.map(|(k, v)| (k.clone(), json_to_wip(v).unwrap()))
.collect();
assert!(matches!(
denied
.call(
"assignment_answer",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert!(client.requests.lock().unwrap().is_empty());
}
@@ -0,0 +1,471 @@
//! Self-reported handling of one immutable delegation. This transport never
//! supplies actor/role authority or converts tracking into execution lifecycle.
//!
//! Updates forward the complete handling payload with the observed event of that exact assignment,
//! not a status-only comparison. A replayed receipt is not a fresh observation for the next update.
//! Query/history may inspect retained assignments and unknown legacy handling; read access does not
//! enable self-report mutation. Unknown handling is not inferred waiting or successful done.
use super::*;
pub(super) const TOOL_NAMES: &[&str] = &[
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentTrackingUpdate",
];
#[derive(Clone, Copy)]
pub(super) enum TrackingToolKind {
Query,
History,
Update,
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct QueryInput {
/// Ticket reference, normally T-*.
ticket: String,
/// Omit for the current delegation; set to inspect a retained assignment and release.
assignment_id: Option<String>,
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct HistoryInput {
ticket: String,
/// Immutable delegation identity, including released or replaced assignments.
assignment_id: String,
/// Continue after an event identity returned by this assignment's history.
after_event_id: Option<String>,
#[schemars(range(min = 1, max = 100))]
limit: Option<usize>,
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct UpdateInput {
ticket: String,
assignment_id: String,
/// Last event observed for this exact delegation, not Ticket status or Worker state.
expected_last_event_id: String,
/// Reuse the same key and complete input after an uncertain result.
operation_key: String,
tracking: ticket::TicketAssignmentTracking,
}
impl TrackingToolKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::Query => TOOL_NAMES[0],
Self::History => TOOL_NAMES[1],
Self::Update => TOOL_NAMES[2],
}
}
fn schema(self) -> Value {
match self {
Self::Query => json!(schemars::schema_for!(QueryInput)),
Self::History => json!(schemars::schema_for!(HistoryInput)),
Self::Update => json!(schemars::schema_for!(UpdateInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
match name {
"TicketAssignmentTrackingQuery" => {
"Read the current Ticket delegation, or one retained assignment_id with its role/principal, Assign actor/time and separate release/replacement record, and durable self-reported waiting/inprogress/done, last event identity, waiting reason, progress, next action and result. Tracking is independent of Ticket open/closed, Worker running/idle/stop, MR and delivery state."
}
"TicketAssignmentTrackingHistory" => {
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
}
"TicketAssignmentTrackingUpdate" => {
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
}
_ => unreachable!("unknown assignment tracking tool"),
}
}
fn required(name: &str, value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() {
return Err(ToolError::InvalidArgument(format!(
"{name} must not be empty"
)));
}
Ok(())
}
fn encode_segment(value: &str) -> String {
value
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
char::from(byte).to_string()
} else {
format!("%{byte:02X}")
}
})
.collect()
}
#[derive(Clone)]
struct TrackingTool {
client: Arc<dyn WorkspaceClient>,
kind: TrackingToolKind,
}
#[async_trait]
impl Tool for TrackingTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
let workspace = self
.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Assignment tracking requires Backend Workspace identity; no local fallback"
.into(),
)
})?;
let prefix = format!("/workspaces/{}/tickets", encode_segment(workspace));
let parse_error = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
let (method, path, body) = match self.kind {
TrackingToolKind::Query => {
let input: QueryInput = serde_json::from_str(input).map_err(parse_error)?;
required("ticket", &input.ticket)?;
let mut path = format!(
"{prefix}/{}/assignment-tracking",
encode_segment(&input.ticket)
);
if let Some(assignment_id) = input.assignment_id {
required("assignment_id", &assignment_id)?;
path.push_str(&format!(
"?assignment_id={}",
encode_segment(&assignment_id)
));
}
(WorkspaceRequestMethod::Get, path, None)
}
TrackingToolKind::History => {
let input: HistoryInput = serde_json::from_str(input).map_err(parse_error)?;
required("ticket", &input.ticket)?;
required("assignment_id", &input.assignment_id)?;
let limit = input.limit.unwrap_or(100);
if !(1..=100).contains(&limit) {
return Err(ToolError::InvalidArgument(
"limit must be between 1 and 100".into(),
));
}
let mut path = format!(
"{prefix}/{}/assignment-tracking/history?assignment_id={}&limit={limit}",
encode_segment(&input.ticket),
encode_segment(&input.assignment_id)
);
if let Some(after) = input.after_event_id {
required("after_event_id", &after)?;
path.push_str(&format!("&after_event_id={}", encode_segment(&after)));
}
(WorkspaceRequestMethod::Get, path, None)
}
TrackingToolKind::Update => {
let input: UpdateInput = serde_json::from_str(input).map_err(parse_error)?;
for (name, value) in [
("ticket", &input.ticket),
("assignment_id", &input.assignment_id),
("expected_last_event_id", &input.expected_last_event_id),
("operation_key", &input.operation_key),
] {
required(name, value)?;
}
// Unknown fields (including author, role, runtime and worker) are
// rejected before transport; authenticated context belongs to Backend.
let body = json!({"assignment_id": input.assignment_id, "expected_last_event_id": input.expected_last_event_id, "operation_key": input.operation_key, "tracking": input.tracking});
(
WorkspaceRequestMethod::Post,
format!(
"{prefix}/{}/assignment-tracking",
encode_segment(&input.ticket)
),
Some(body.to_string()),
)
}
};
let request = match body {
Some(body) => WorkspaceRequest::json(method, path, body),
None => WorkspaceRequest::get(path),
};
let response = self
.client
.execute(request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
let detail: String = response.body.chars().take(1024).collect();
return Err(ToolError::ExecutionFailed(format!(
"Assignment tracking request failed with HTTP status {}: {}",
response.status, detail
)));
}
if response.body.len() > 1024 * 1024 {
return Err(ToolError::ExecutionFailed(
"Assignment tracking response exceeds 1 MiB; request a smaller history page".into(),
));
}
let content: Value = serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!(
"Backend returned invalid assignment tracking JSON: {error}"
))
})?;
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(content.to_string()),
attachments: Vec::new(),
})
}
}
pub(super) fn definition(
client: Arc<dyn WorkspaceClient>,
kind: TrackingToolKind,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(TrackingTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
#[derive(Debug)]
struct Transport {
workspace: Option<String>,
response: WorkspaceResponse,
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for Transport {
fn workspace_id(&self) -> Option<&str> {
self.workspace.as_deref()
}
fn kind(&self) -> &str {
"tracking-contract-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(self.response.clone())
}
}
fn transport(status: u16, body: &str) -> Arc<Transport> {
Arc::new(Transport {
workspace: Some("workspace".into()),
response: WorkspaceResponse {
status,
body: body.into(),
},
requests: Mutex::new(Vec::new()),
})
}
fn update() -> Value {
json!({"ticket":"T-741", "assignment_id":"delegation", "expected_last_event_id":"assigned-event", "operation_key":"report", "tracking":{"status":"done", "waiting_reason":null, "progress":"Alternatives compared", "next_action":"Human decision", "result":"Returned options"}})
}
#[tokio::test]
async fn assignment_tracking_update_requires_observed_identity_and_excludes_actor_claims() {
let client = transport(
200,
r#"{"assignment_id":"delegation","last_event_id":"reported-event","tracking":{"status":"done"}}"#,
);
let (meta, tool) = definition(client.clone(), TrackingToolKind::Update)();
assert_eq!(meta.name, "TicketAssignmentTrackingUpdate");
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(validator.is_valid(&update()));
for field in [
"assignment_id",
"expected_last_event_id",
"operation_key",
"tracking",
] {
let mut input = update();
input.as_object_mut().unwrap().remove(field);
assert!(!validator.is_valid(&input), "required {field}");
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
}
for field in [
"author",
"role",
"runtime_id",
"worker_id",
"source_context",
] {
let mut input = update();
input[field] = json!("orchestrator");
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err(),
"reject {field}"
);
}
assert!(client.requests.lock().unwrap().is_empty());
for _ in 0..2 {
let output = tool
.execute(&update().to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(&output.content.unwrap()).unwrap()["last_event_id"],
"reported-event"
);
}
let requests = client.requests.lock().unwrap();
assert_eq!(
requests[0], requests[1],
"uncertain retry preserves full input"
);
assert_eq!(
requests[0].path,
"/workspaces/workspace/tickets/T-741/assignment-tracking"
);
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
assert_eq!(body["tracking"]["status"], "done");
assert!(body.get("ticket").is_none());
assert!(body.get("author").is_none());
}
#[tokio::test]
async fn assignment_tracking_reads_are_bounded_and_carry_history_cursor() {
let client = transport(200, r#"{"items":[],"next_event_id":null}"#);
let (_, tool) = definition(client.clone(), TrackingToolKind::History)();
for limit in [0, 101] {
assert!(
tool.execute(
&json!({"ticket":"T-741", "assignment_id":"a", "limit":limit}).to_string(),
ToolExecutionContext::direct()
)
.await
.is_err()
);
}
tool.execute(
&json!({"ticket":"T-741", "assignment_id":"a", "after_event_id":"event/1", "limit":10})
.to_string(),
ToolExecutionContext::direct(),
)
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert!(
requests[0]
.path
.ends_with("?assignment_id=a&limit=10&after_event_id=event%2F1")
);
drop(requests);
let oversized = transport(200, &"x".repeat(1024 * 1024 + 1));
let (_, tool) = definition(oversized, TrackingToolKind::Query)();
assert!(
tool.execute(r#"{"ticket":"T-741"}"#, ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("1 MiB")
);
}
#[tokio::test]
async fn retained_assignment_query_encodes_identity_without_changing_workspace_scope() {
let client = transport(
200,
r#"[{"assignment_id":"old/delegation?x","current":false,"release":{"action":"replaced"}}]"#,
);
let (_, tool) = definition(client.clone(), TrackingToolKind::Query)();
let output = tool
.execute(
r#"{"ticket":"T-741","assignment_id":"old/delegation?x"}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
let content: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(content[0]["current"], false);
assert_eq!(content[0]["release"]["action"], "replaced");
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert_eq!(
requests[0].path,
"/workspaces/workspace/tickets/T-741/assignment-tracking?assignment_id=old%2Fdelegation%3Fx"
);
}
#[tokio::test]
async fn assignment_tracking_requires_backend_and_propagates_authority_rejection() {
let client = Arc::new(Transport {
workspace: None,
response: WorkspaceResponse {
status: 200,
body: "{}".into(),
},
requests: Mutex::new(Vec::new()),
});
let (_, tool) = definition(client.clone(), TrackingToolKind::Update)();
assert!(
tool.execute(&update().to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("Backend Workspace identity")
);
assert!(client.requests.lock().unwrap().is_empty());
let client = transport(403, r#"{"code":"current_orchestrator_required"}"#);
let (_, tool) = definition(client, TrackingToolKind::Update)();
assert!(
tool.execute(&update().to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("403")
);
}
#[test]
fn assignment_tracking_tools_share_native_wip_and_access_contract() {
let client = transport(200, "{}");
let definitions = enabled_ticket_definitions(client, TicketFeatureAccess::workflow());
for name in TOOL_NAMES {
let (meta, _) = definitions
.iter()
.find(|definition| definition().0.name == *name)
.unwrap()();
let native = native_ticket_operation(name).unwrap();
assert_eq!(native.identity_field, Some("ticket"));
assert_eq!(native.mutating, *name == "TicketAssignmentTrackingUpdate");
assert!(
meta.description.contains("delegation")
|| meta.description.contains("assignment_id")
);
}
let reads = TicketFeatureAccess::read_only().tool_names();
assert!(reads.contains(&"TicketAssignmentTrackingQuery"));
assert!(reads.contains(&"TicketAssignmentTrackingHistory"));
assert!(!reads.contains(&"TicketAssignmentTrackingUpdate"));
}
}
@@ -0,0 +1,284 @@
//! Classification tools share the Ticket feature's scoped Backend transport.
#[cfg(test)]
mod tests;
use super::*;
pub(super) const TAG_TOOL_NAMES: &[&str] = &[
"WorkspaceTagList",
"WorkspaceTagCreate",
"WorkspaceTagRename",
"WorkspaceTagDelete",
"TicketTagsSet",
];
#[derive(Clone, Copy)]
pub(super) enum TagToolKind {
List,
Create,
Rename,
Delete,
Set,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListInput {
/// Continue strictly after this stable tag ID. Continue until an empty page.
after: Option<String>,
/// Maximum results, 0..=200; defaults to 200.
#[schemars(range(max = 200))]
limit: Option<usize>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct CreateInput {
/// Display name: nonempty after Unicode trimming, no controls, at most 128 UTF-8 bytes.
name: String,
/// Retry an uncertain request with this same key and exact input.
operation_key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct RenameInput {
tag_id: String,
/// Last operation key observed on this exact identity; rename-back is still a change.
expected_last_operation_key: String,
name: String,
operation_key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DeleteInput {
tag_id: String,
expected_last_operation_key: String,
operation_key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SetInput {
/// Ticket reference, preferably T-*; WIP binds it from the item route.
ticket: String,
/// Entire active ID set observed in ShowTicket/QueryTicket; never silently refreshed.
#[schemars(length(max = 100))]
expected_tag_ids: Vec<String>,
/// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all.
#[schemars(length(max = 100))]
tag_ids: Vec<String>,
operation_key: String,
}
impl TagToolKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::List => "WorkspaceTagList",
Self::Create => "WorkspaceTagCreate",
Self::Rename => "WorkspaceTagRename",
Self::Delete => "WorkspaceTagDelete",
Self::Set => "TicketTagsSet",
}
}
fn schema(self) -> Value {
match self {
Self::List => json!(schemars::schema_for!(ListInput)),
Self::Create => json!(schemars::schema_for!(CreateInput)),
Self::Rename => json!(schemars::schema_for!(RenameInput)),
Self::Delete => json!(schemars::schema_for!(DeleteInput)),
Self::Set => json!(schemars::schema_for!(SetInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
match name {
"WorkspaceTagList" => {
"List active Workspace classification tags in stable ID order, bounded to 200. Continue after the last ID until exhausted; a full page does not promise another page."
}
"WorkspaceTagCreate" => {
"Create a Workspace classification tag with a stable identity and replay key. Names are ordinary classification, never Ticket states, routing, or authority grants."
}
"WorkspaceTagRename" => {
"Rename a Workspace classification tag by stable ID and observed last_operation_key. Links retain identity; history is not rewritten. Retry uncertain requests with the same key/input, then reread before further edits."
}
"WorkspaceTagDelete" => {
"Tombstone a Workspace classification tag using its observed last_operation_key and atomically remove current Ticket membership. Prior history remains; recreating the name creates a new identity."
}
"TicketTagsSet" => {
"Atomically compare and replace a Ticket's entire active classification tag ID set. Compute desired tag_ids from observed expected_tag_ids to attach/remove multiple tags without overwriting unrelated changes. At most 100 unique IDs. Classification does not edit state, content digest, assignment, or grants. A replay is the original receipt, not a fresh observation; reread before further edits."
}
_ => unreachable!("unknown classification tool"),
}
}
fn required(name: &str, value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() {
return Err(ToolError::InvalidArgument(format!(
"{name} must not be empty"
)));
}
Ok(())
}
fn validate_name(name: &str) -> Result<(), ToolError> {
if name.trim().is_empty() || name.trim().len() > 128 || name.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument("name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
}
Ok(())
}
pub(super) fn validate_ids(name: &str, ids: &[String]) -> Result<(), ToolError> {
if ids.len() > 100 {
return Err(ToolError::InvalidArgument(format!(
"{name} must contain at most 100 IDs"
)));
}
let mut unique = BTreeSet::new();
for id in ids {
required(name, id)?;
if !unique.insert(id) {
return Err(ToolError::InvalidArgument(format!(
"{name} must contain unique IDs"
)));
}
}
Ok(())
}
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
#[derive(Clone)]
struct TagTool {
client: Arc<dyn WorkspaceClient>,
kind: TagToolKind,
}
#[async_trait]
impl Tool for TagTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Classification tools require Backend Workspace identity; no local fallback"
.into(),
)
})?;
let backend = WorkspaceHttpTicketBackend::new(self.client.clone());
let content = match self.kind {
TagToolKind::List => {
let input: ListInput = parse(input)?;
if input.limit.is_some_and(|limit| limit > 200) {
return Err(ToolError::InvalidArgument(
"limit must be at most 200".into(),
));
}
if let Some(after) = &input.after {
required("after", after)?;
}
let limit = input.limit.unwrap_or(200);
let mut tags = backend
.list_workspace_tags(ticket::TagListRequest {
after: input.after,
limit: Some(limit),
})
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
tags.truncate(limit);
json!(tags)
}
TagToolKind::Set => {
let input: SetInput = parse(input)?;
required("ticket", &input.ticket)?;
required("operation_key", &input.operation_key)?;
validate_ids("expected_tag_ids", &input.expected_tag_ids)?;
validate_ids("tag_ids", &input.tag_ids)?;
let tags = backend
.set_ticket_tags(
TicketIdOrSlug::from(input.ticket.as_str()),
ticket::TicketTagSetRequest {
expected_tag_ids: input.expected_tag_ids,
tag_ids: input.tag_ids,
operation_key: input.operation_key,
},
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if tags.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Backend returned more than 100 Ticket tags".into(),
));
}
json!(tags)
}
kind => {
let request = match kind {
TagToolKind::Create => {
let input: CreateInput = parse(input)?;
validate_name(&input.name)?;
ticket::WorkspaceTagMutation {
operation_key: input.operation_key,
action: ticket::WorkspaceTagAction::Create { name: input.name },
}
}
TagToolKind::Rename => {
let input: RenameInput = parse(input)?;
required("tag_id", &input.tag_id)?;
required(
"expected_last_operation_key",
&input.expected_last_operation_key,
)?;
validate_name(&input.name)?;
ticket::WorkspaceTagMutation {
operation_key: input.operation_key,
action: ticket::WorkspaceTagAction::Rename {
tag_id: input.tag_id,
expected_last_operation_key: input.expected_last_operation_key,
name: input.name,
},
}
}
TagToolKind::Delete => {
let input: DeleteInput = parse(input)?;
required("tag_id", &input.tag_id)?;
required(
"expected_last_operation_key",
&input.expected_last_operation_key,
)?;
ticket::WorkspaceTagMutation {
operation_key: input.operation_key,
action: ticket::WorkspaceTagAction::Delete {
tag_id: input.tag_id,
expected_last_operation_key: input.expected_last_operation_key,
},
}
}
_ => unreachable!(),
};
required("operation_key", &request.operation_key)?;
json!(
backend
.mutate_workspace_tag(request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
)
}
};
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(content.to_string()),
attachments: Vec::new(),
})
}
}
pub(super) fn definition(client: Arc<dyn WorkspaceClient>, kind: TagToolKind) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(TagTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,561 @@
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
#[derive(Debug)]
struct RecordingClient {
requests: Mutex<Vec<WorkspaceRequest>>,
body: String,
identity: bool,
status: u16,
}
impl WorkspaceClient for RecordingClient {
fn workspace_id(&self) -> Option<&str> {
self.identity.then_some("workspace")
}
fn kind(&self) -> &str {
"classification-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: self.status,
body: self.body.clone(),
})
}
}
fn recording(body: Value) -> Arc<RecordingClient> {
Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: body.to_string(),
identity: true,
status: 200,
})
}
fn tag() -> Value {
json!({"workspace_id":"workspace", "id":"tag-1", "name":"Ready", "last_operation_key":"create-1", "deleted":false})
}
fn cases() -> Vec<(TagToolKind, Value, &'static str)> {
vec![
(TagToolKind::List, json!({"limit":5}), "WorkspaceTagList"),
(
TagToolKind::Create,
json!({"name":"Ready", "operation_key":"create-1"}),
"WorkspaceTagCreate",
),
(
TagToolKind::Rename,
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "name":"New", "operation_key":"rename-1"}),
"WorkspaceTagRename",
),
(
TagToolKind::Delete,
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "operation_key":"delete-1"}),
"WorkspaceTagDelete",
),
(
TagToolKind::Set,
json!({"ticket":"T-740", "expected_tag_ids":[], "tag_ids":["tag-1", "tag-2"], "operation_key":"set-1"}),
"TicketTagsSet",
),
]
}
#[tokio::test]
async fn classification_tool_schemas_require_observations_and_reject_actor_claims() {
for (kind, input, name) in cases() {
let client = recording(json!([]));
let (meta, tool) = definition(client.clone(), kind)();
assert_eq!(meta.name, name);
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(validator.is_valid(&input), "{name}: {}", meta.input_schema);
for field in meta.input_schema["required"]
.as_array()
.into_iter()
.flatten()
{
let field = field.as_str().unwrap();
let mut invalid = input.clone();
invalid.as_object_mut().unwrap().remove(field);
assert!(!validator.is_valid(&invalid), "{name}.{field}");
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
let mut invalid = input.clone();
invalid["author"] = json!("orchestrator");
assert!(!validator.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
assert!(client.requests.lock().unwrap().is_empty());
}
let schema = TagToolKind::Set.schema();
assert_eq!(schema["properties"]["tag_ids"]["maxItems"], 100);
assert_eq!(schema["properties"]["expected_tag_ids"]["maxItems"], 100);
assert_eq!(
TagToolKind::List.schema()["properties"]["limit"]["maximum"],
200
);
}
#[tokio::test]
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}));
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
assert_eq!(meta.input_schema["properties"][field]["maxItems"], 100);
}
let input = json!({"tag_ids":["tag-1"], "tag_ids_any":["tag-2", "tag-3"], "tag_ids_none":["tag-4"], "states":["open"], "limit":7});
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value["tickets"][0]["tags"], json!([tag()]));
assert_eq!(value["tickets"][0]["state"], "open");
for field in ["closure_operation_key", "current_closure", "legacy_handoff"] {
assert!(
value["tickets"][0].as_object().unwrap().contains_key(field),
"{field}"
);
assert_eq!(value["tickets"][0][field], Value::Null);
}
{
let requests = client.requests.lock().unwrap();
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
for field in ["states", "tag_ids", "tag_ids_any", "tag_ids_none"] {
assert_eq!(body[field], input[field]);
}
}
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let client = recording(detail);
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
let output = tool
.execute(r#"{"id":"T-740"}"#, ToolExecutionContext::direct())
.await
.unwrap();
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value["tags"], json!([tag()]));
assert_eq!(value["content_digest"], "content-1");
assert_eq!(value["state"], "open");
assert_eq!(value["closure_history"], json!([]));
for field in ["closure_operation_key", "current_closure", "legacy_handoff"] {
assert!(value.as_object().unwrap().contains_key(field), "{field}");
assert_eq!(value[field], Value::Null);
}
let client = recording(json!({}));
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
let input = json!({field: (0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()});
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"invalid",
] {
let input = json!({"states":[state], "tag_ids":["tag-1"]});
assert!(
matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
),
"{state}"
);
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn classification_tools_reject_invalid_names_ids_and_keys_before_dispatch() {
let client = recording(json!([]));
for name in [" ".to_string(), "x\n".to_string(), "é".repeat(65)] {
let (_, tool) = definition(client.clone(), TagToolKind::Create)();
assert!(matches!(
tool.execute(
&json!({"name":name, "operation_key":"create"}).to_string(),
ToolExecutionContext::direct()
)
.await,
Err(ToolError::InvalidArgument(_))
));
}
for ids in [
json!(["tag-1", "tag-1"]),
json!([""]),
json!((0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()),
] {
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
for field in ["tag_ids", "expected_tag_ids"] {
let mut input = json!({"ticket":"T-740", "tag_ids":[], "expected_tag_ids":[], "operation_key":"set"});
input[field] = ids.clone();
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
}
for (kind, mut input, _) in cases()
.into_iter()
.filter(|(kind, _, _)| !matches!(kind, TagToolKind::List))
{
input["operation_key"] = json!(" ");
let (_, tool) = definition(client.clone(), kind)();
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
let (_, tool) = definition(client.clone(), TagToolKind::List)();
assert!(
tool.execute(r#"{"limit":201}"#, ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn classification_tools_require_backend_workspace_and_bound_list_results() {
let missing = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: "[]".into(),
identity: false,
status: 200,
});
for (kind, input, _) in cases() {
let (_, tool) = definition(missing.clone(), kind)();
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(missing.requests.lock().unwrap().is_empty());
let client = recording(json!(vec![tag(); 201]));
let (_, tool) = definition(client.clone(), TagToolKind::List)();
for (input, expected) in [
(json!({}), 200),
(json!({"limit":2, "after":"a&b"}), 2),
(json!({"limit":0}), 0),
] {
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value.as_array().unwrap().len(), expected);
}
let requests = client.requests.lock().unwrap();
assert_eq!(
requests[1].path,
"/api/w/workspace/ticket-tags?limit=2&after=a%26b"
);
}
#[tokio::test]
async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() {
let client = recording(json!([tag()]));
let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap();
let set = tools
.iter()
.find(|tool| tool.name == "TicketTagsSet")
.unwrap();
let (_, input, _) = cases().pop().unwrap();
let permissions = Some(ToolPermissionConfig {
default_action: ToolPermissionAction::Allow,
rules: vec![manifest::ToolPermissionRule {
tool: "TicketTagsSet".into(),
action: ToolPermissionAction::Deny,
pattern: input.to_string(),
}],
});
let error = execute_native_ticket_tool(
set,
&permissions,
input,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_err();
assert!(matches!(
error,
WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
})
));
assert!(client.requests.lock().unwrap().is_empty());
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-740".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
handler
.observations
.lock()
.unwrap()
.content_digests
.insert("T-740".into(), "content-1".into());
let mut args = BTreeMap::from([
("operation_key".into(), WipValue::String("set-1".into())),
("tag_ids".into(), json_to_wip(&json!(["tag-1"])).unwrap()),
("expected_tag_ids".into(), json_to_wip(&json!([])).unwrap()),
]);
assert!(
handler
.call(
"tags_set",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.is_ok()
);
assert_eq!(
client.requests.lock().unwrap()[0].path,
"/api/w/workspace/tickets/T-740/tags"
);
assert_eq!(
handler
.observations
.lock()
.unwrap()
.content_digests
.get("T-740")
.map(String::as_str),
Some("content-1")
);
args.insert("ticket".into(), WipValue::String("T-foreign".into()));
assert!(
handler
.call(
"tags_set",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let read_only = TicketFeatureAccess::read_only().tool_names();
assert!(read_only.contains(&"WorkspaceTagList"));
assert!(!read_only.contains(&"TicketTagsSet"));
}
#[tokio::test]
async fn classification_tools_preserve_backend_permission_and_stale_set_failures() {
for status in [403, 409] {
let client = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: "{}".into(),
identity: true,
status,
});
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
let (_, input, _) = cases().pop().unwrap();
let error = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains(&status.to_string()));
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
}
#[test]
fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare() {
use std::io::{Read, Write};
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("tags.db");
let backend = ticket::SqliteTicketBackend::open(&db, "workspace").unwrap();
let reference = backend.create(NewTicket::new("Classify")).unwrap();
let initial_content_digest =
ticket::ticket_content_digest(&backend.show(reference.id.clone().into()).unwrap());
let ticket_id = reference.id.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server_db = db.clone();
let server = std::thread::spawn(move || {
for _ in 0..10 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.unwrap();
let mut bytes = Vec::new();
let (headers, body) = loop {
let mut chunk = [0u8; 8192];
let count = stream.read(&mut chunk).unwrap();
assert!(count > 0);
bytes.extend_from_slice(&chunk[..count]);
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
let headers = String::from_utf8(bytes[..end].to_vec()).unwrap();
let length = headers
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if bytes.len() >= end + 4 + length {
break (headers, bytes[end + 4..end + 4 + length].to_vec());
}
}
};
let backend =
ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap();
let line = headers.lines().next().unwrap();
let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") {
backend
.mutate_workspace_tag(serde_json::from_slice(&body).unwrap())
.map(|tag| json!(tag))
} else if line.starts_with(&format!("PUT /api/w/workspace/tickets/{ticket_id}/tags ")) {
backend
.set_ticket_tags(
ticket_id.clone().into(),
serde_json::from_slice(&body).unwrap(),
)
.map(|tags| json!(tags))
} else {
assert!(
line.starts_with("GET /api/w/workspace/ticket-tags?"),
"{line}"
);
backend
.list_workspace_tags(ticket::TagListRequest::default())
.map(|tags| json!(tags))
};
let (status, response) = match result {
Ok(value) => ("200 OK", value.to_string()),
Err(error) => (
"409 Conflict",
json!({"message":error.to_string()}).to_string(),
),
};
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
}
});
let client: Arc<dyn WorkspaceClient> = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
base,
));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let call = |kind, input: Value| {
runtime.block_on(async {
let (_, tool) = definition(client.clone(), kind)();
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
})
};
let first: ticket::WorkspaceTag = serde_json::from_str(
call(
TagToolKind::Create,
json!({"name":"ready", "operation_key":"create-a"}),
)
.unwrap()
.content
.as_deref()
.unwrap(),
)
.unwrap();
let second: ticket::WorkspaceTag = serde_json::from_str(
call(
TagToolKind::Create,
json!({"name":"closed", "operation_key":"create-b"}),
)
.unwrap()
.content
.as_deref()
.unwrap(),
)
.unwrap();
let set = json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"set-both"});
let receipt = call(TagToolKind::Set, set.clone()).unwrap();
assert_eq!(
call(TagToolKind::Set, set).unwrap().content,
receipt.content
);
assert!(call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[], "operation_key":"stale"})).is_err());
let removed = call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[second.id, first.id], "tag_ids":[], "operation_key":"remove-both"})).unwrap();
assert_eq!(removed.content.as_deref(), Some("[]"));
assert!(
backend
.show(reference.id.clone().into())
.unwrap()
.tags
.is_empty()
);
call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"reattach-both"})).unwrap();
call(TagToolKind::Rename, json!({"tag_id":first.id, "expected_last_operation_key":first.last_operation_key, "name":"renamed", "operation_key":"rename-a"})).unwrap();
call(TagToolKind::Delete, json!({"tag_id":second.id, "expected_last_operation_key":second.last_operation_key, "operation_key":"delete-b"})).unwrap();
let listed = call(TagToolKind::List, json!({})).unwrap();
let tags: Vec<ticket::WorkspaceTag> =
serde_json::from_str(listed.content.as_deref().unwrap()).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "renamed");
server.join().unwrap();
let reopened = ticket::SqliteTicketBackend::open_verified(&db, "workspace").unwrap();
let ticket = reopened.show(reference.id.into()).unwrap();
assert_eq!(ticket.tags, tags);
assert_eq!(
ticket::ticket_content_digest(&ticket),
initial_content_digest
);
assert_eq!(ticket.meta.workflow_state, TicketState::Open);
assert!(ticket.current_closure.is_none());
assert!(ticket.closure_operation_key.is_none());
assert!(ticket.case_history.is_empty());
assert_eq!(
ticket
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.count(),
4,
"replay and stale input must not append membership events"
);
}
+72 -4
View File
@@ -1090,12 +1090,22 @@ impl Tool for McpStdioTool {
&self,
input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
self.execute_with_limits(input_json, McpStdioLimits::default())
.await
}
}
impl McpStdioTool {
async fn execute_with_limits(
&self,
input_json: &str,
limits: McpStdioLimits,
) -> Result<ToolOutput, ToolError> {
let arguments = parse_tool_arguments(input_json)?;
let mut client =
McpStdioClient::connect(self.server_spec.clone(), McpStdioLimits::default())
.await
.map_err(|err| ToolError::ExecutionFailed(mcp_call_error_message(&err)))?;
let mut client = McpStdioClient::connect(self.server_spec.clone(), limits)
.await
.map_err(|err| ToolError::ExecutionFailed(mcp_call_error_message(&err)))?;
let call_result = client
.call_tool(CallToolRequest::new(self.mcp_tool_name.clone(), arguments))
@@ -2217,6 +2227,64 @@ read exit_notification || true
);
}
#[cfg(unix)]
#[tokio::test]
async fn stdio_tool_request_timeout_reports_unknown_execution_without_replay() {
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("frames.jsonl");
// Frame capture is observed only after shutdown has waited for process
// exit. No guessed sleep or process-global test-control variable.
let script = r#"
printf '%s\n' 'connection' >> "$1"
read -r init || exit 1
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"timeout-fixture","version":"1"}}}'
read -r initialized || exit 1
while IFS= read -r frame; do
printf '%s\n' "$frame" >> "$1"
done
"#;
let tool = McpStdioTool {
server_spec: McpStdioServerSpec::new("timeout-fixture", "/bin/sh").args([
"-c".to_string(),
script.to_string(),
"fixture".to_string(),
log.to_str().unwrap().to_string(),
]),
mcp_tool_name: "demo-tool".to_string(),
};
let limits = McpStdioLimits {
request_timeout: std::time::Duration::from_millis(100),
shutdown_timeout: std::time::Duration::from_millis(100),
kill_timeout: std::time::Duration::from_millis(100),
..Default::default()
};
let err = tokio::time::timeout(
std::time::Duration::from_secs(3),
tool.execute_with_limits("{}", limits),
)
.await
.unwrap()
.expect_err("response intentionally withheld");
let message = err.to_string();
assert!(
message.contains("tools/call transport/protocol failure"),
"{message}"
);
assert!(message.contains("timed out during tools/call"), "{message}");
assert!(
message.contains("frame flushed; execution unknown; do not retry"),
"{message}"
);
let capture = std::fs::read_to_string(log).unwrap();
let mut lines = capture.lines();
assert_eq!(lines.next(), Some("connection"));
let frames: Vec<Value> = lines
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(frames.len(), 1, "must not reconnect or replay: {capture}");
assert_eq!(frames[0]["method"], "tools/call");
}
#[test]
fn call_tool_result_renderer_marks_untrusted_mcp_error() {
let result = CallToolResult {
+8
View File
@@ -68,6 +68,7 @@ pub(crate) struct InternalWorkerSpec {
) + Send,
>,
>,
pub compaction_budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
pub features: FeatureRegistryBuilder,
pub required_tools: &'static [&'static str],
pub authority: InternalWorkerAuthority,
@@ -118,6 +119,7 @@ where
cache_key,
max_turns,
engine_configurator,
compaction_budget,
features,
required_tools,
authority,
@@ -171,6 +173,7 @@ where
identity: identity.clone(),
history_entries: 0,
})?;
worker.install_compaction_budget(compaction_budget);
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_single_workdir_session(Some(session));
@@ -666,6 +669,7 @@ pub(crate) async fn spawn_internal_worker_session(
cache_key,
max_turns,
engine_configurator,
compaction_budget,
features,
required_tools,
authority,
@@ -701,6 +705,7 @@ pub(crate) async fn spawn_internal_worker_session(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
worker.install_compaction_budget(compaction_budget);
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_single_workdir_session(Some(session));
@@ -762,6 +767,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
cache_key,
max_turns,
engine_configurator,
compaction_budget,
features,
required_tools,
authority,
@@ -794,6 +800,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
worker.install_compaction_budget(compaction_budget);
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_single_workdir_session(Some(session));
@@ -1442,6 +1449,7 @@ permission = "write"
input: "input".to_string(),
cache_key: Some("internal-test".to_string()),
max_turns: Some(1),
compaction_budget: None,
engine_configurator: None,
features: FeatureRegistryBuilder::new(),
required_tools,
+40 -4
View File
@@ -74,6 +74,7 @@ async fn call_hook_before_deadline<E: HookEventKind>(
}
pub(crate) struct WorkerInterceptor {
compaction_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
/// Shared view of the cumulative UsageRecord timeline. Used with the
@@ -157,6 +158,7 @@ impl WorkerInterceptor {
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
) -> Self {
Self {
compaction_budget: None,
registry,
compact_state,
usage_history,
@@ -175,6 +177,14 @@ impl WorkerInterceptor {
}
}
pub(crate) fn with_compaction_budget(
mut self,
budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
) -> Self {
self.compaction_budget = budget;
self
}
pub(crate) fn with_notification_committer(
mut self,
committer: Option<Arc<dyn DurableNotificationCommitter>>,
@@ -552,6 +562,8 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
&self,
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PreRequestAction> {
let context_invocation = context.invocation;
let context_history = context.history;
let context = context.items;
let initial_tokens = self.estimated_tokens(context);
if let Some(action) =
@@ -616,7 +628,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
.expect("pending hook system-item queue poisoned"),
);
self.attach_prompt_provenance(&mut system_items);
let appended_items: Vec<Item> = system_items
let mut appended_items: Vec<Item> = system_items
.iter()
.map(SystemItem::to_history_item)
.collect();
@@ -652,10 +664,28 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
});
}
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
usage_tracker.note_request(effective_context.len());
// Internal compaction safety must survive the normal Worker interceptor
// installation. Run it on the complete request after durable hook additions;
// Engine persists any returned warning through its normal append path.
if let Some(budget) = self.compaction_budget.as_ref() {
let mut budget_context = effective_context.into_owned();
match budget
.pre_llm_request(PreLlmRequestContext {
invocation: context_invocation,
items: &mut budget_context,
history: context_history,
})
.await?
{
PreRequestAction::Continue => {}
PreRequestAction::ContinueWith(warnings) => appended_items.extend(warnings),
action => return Ok(action),
}
}
if system_items.is_empty() {
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
usage_tracker.note_request(context.len() + appended_items.len());
}
if appended_items.is_empty() {
return Ok(PreRequestAction::Continue);
}
Ok(match self.commit_system_items(&system_items) {
@@ -700,6 +730,12 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
if let Some(reason) = denials.into_iter().next() {
return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone()));
}
if let Some(budget) = self.compaction_budget.as_ref() {
match budget.pre_tool_call(info).await? {
PreToolAction::Continue => {}
action => return Ok(action),
}
}
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
Ok(PreToolAction::Continue)
}
+1
View File
@@ -224,6 +224,7 @@ impl PreparedInternalJob {
run_id: uuid::Uuid::now_v7(),
},
max_turns: engine_policy.max_turns.map(|n| n.get()),
compaction_budget: None,
engine_configurator: Some(Box::new(move |engine| {
crate::apply_worker_manifest(engine, &engine_policy);
})),
+54 -5
View File
@@ -156,11 +156,15 @@ impl WorkerPrompt {
];
}
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
/// Model-visible Ticket projection shared by Server and TUI attention paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionTicket {
resource_key: String,
title: String,
assignment_id: Option<String>,
input_event_id: Option<String>,
waiting_reason: Option<String>,
next_action: Option<String>,
}
impl OrchestratorQueueAttentionTicket {
@@ -175,8 +179,28 @@ impl OrchestratorQueueAttentionTicket {
Ok(Self {
resource_key,
title: bounded_queue_attention_text(&title.into(), 240),
assignment_id: None,
input_event_id: None,
waiting_reason: None,
next_action: None,
})
}
/// Committed delegation/input identity, not a handling-status transition.
/// The receiving Worker must reread current authority before a new action.
pub fn with_assignment_input(
mut self,
assignment_id: &str,
input_event_id: &str,
waiting_reason: Option<&str>,
next_action: Option<&str>,
) -> Self {
self.assignment_id = Some(bounded_queue_attention_text(assignment_id, 1024));
self.input_event_id = Some(bounded_queue_attention_text(input_event_id, 1024));
self.waiting_reason = waiting_reason.map(|text| bounded_queue_attention_text(text, 1024));
self.next_action = next_action.map(|text| bounded_queue_attention_text(text, 1024));
self
}
}
/// Shared model-visible context for every Orchestrator backlog attention renderer.
@@ -200,7 +224,7 @@ impl OrchestratorQueueAttentionContext {
}
}
/// Prompt-catalog entries that must share the same backlog-attention body contract.
/// Prompt-catalog entries sharing a bounded Ticket resource-key projection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorQueueAttentionPrompt {
Server,
@@ -796,7 +820,7 @@ mod tests {
"ReviewMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
"CompleteTicket",
"TicketCaseUpdate",
] {
assert!(combined.contains(operation), "missing {operation}");
}
@@ -841,8 +865,11 @@ mod tests {
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
.unwrap();
assert_eq!(server, tui);
assert!(server.starts_with("Queued Tickets require attention:"));
assert!(server.starts_with("Assigned Tickets have unreceived input:"));
assert!(tui.starts_with("Assigned Tickets have input requiring attention:"));
assert!(tui.contains("no ready/Queue step is required"));
assert!(tui.contains("were omitted from this notice: 1"));
assert!(!tui.contains("T-21"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
@@ -859,6 +886,28 @@ mod tests {
}
}
#[test]
fn assignment_attention_carries_input_identity_and_bounded_waiting_context() {
let ticket = OrchestratorQueueAttentionTicket::new("T-742", "Partial work")
.unwrap()
.with_assignment_input(
"assignment-a",
"answer-event",
Some("human\nanswer"),
Some(&"x".repeat(2048)),
);
let context = OrchestratorQueueAttentionContext::new(vec![ticket]);
let output = PromptCatalog::builtins_only()
.unwrap()
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
assert!(output.contains("Delegation: assignment-a; input: answer-event."));
assert!(output.contains("Waiting reason: human answer"));
assert!(output.contains(&format!("Next action: {}…", "x".repeat(1024))));
assert!(!output.contains(&"x".repeat(1025)));
assert!(output.contains("no ready/Queue step is required"));
}
#[test]
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
let catalog = PromptCatalog::builtins_only().unwrap();
+162 -12
View File
@@ -334,19 +334,28 @@ mod tests {
fn ticket_workflow_fixture(
role: &str,
mr_config: MergeRequestFeatureConfig,
) -> (String, Vec<agen::tool::ToolMeta>) {
ticket_fixture_with_access(
role,
mr_config,
TicketFeatureAccess {
thread: true,
workflow: true,
..Default::default()
},
)
}
fn ticket_fixture_with_access(
role: &str,
mr_config: MergeRequestFeatureConfig,
access: TicketFeatureAccess,
) -> (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(TicketFeature::new(client.clone(), access))
.with_module(MergeRequestFeature::new(client, mr_config))
.install_into_pending(&mut tools, &mut hooks);
assert!(!report.has_errors(), "{}", report.error_message());
@@ -384,12 +393,12 @@ mod tests {
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
let complete = tools
.iter()
.find(|meta| meta.name == "CompleteTicket")
.find(|meta| meta.name == "TicketCaseUpdate")
.unwrap();
assert!(
complete
.description
.contains("Merge Requests and approvals are not prerequisites")
.contains("Repository, Merge Request and approval evidence are not prerequisites")
);
assert!(complete.description.contains("not real judgment"));
assert!(
@@ -461,12 +470,153 @@ mod tests {
);
let complete = tools
.iter()
.find(|meta| meta.name == "CompleteTicket")
.find(|meta| meta.name == "TicketCaseUpdate")
.unwrap();
assert!(
complete
.description
.contains("return without conclusion need not call this tool")
.contains("Return without conclusion need not call this tool")
);
}
}
#[test]
fn effective_ticket_instructions_separate_case_handling_and_delegation_retries() {
for role in [
"role.ticket_worker",
"role.coder",
"role.orchestrator",
"role.intake",
] {
let (prompt, tools) =
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
for policy in [
"only `open`",
"stable tag IDs, not display names",
"no ready step or separate Queue is required",
"Orchestrator's self-reported handling",
"Answers during waiting or ongoing handling continue the same assignment row",
"explicit reissue creates a new row/assignment_id",
"Respect an explicit user approval condition",
"done does not require unassignment",
"expected_last_event_id",
"Exact retries retain the same key and full input",
"Assign persistence, notification delivery, Runtime acceptance and actual work start",
"concrete API insufficiency",
] {
assert!(prompt.contains(policy), "{role}: missing {policy}");
}
let update = tools
.iter()
.find(|meta| meta.name == "TicketAssignmentTrackingUpdate")
.unwrap();
for policy in [
"self-reported",
"same row",
"new Assign row",
"complete tracking payload",
"different input with that key is rejected",
"reread current authority",
"exact retry receipts are not fresh observations",
] {
assert!(
update.description.contains(policy),
"{role}: missing {policy}"
);
}
assert!(!tools.iter().any(|meta| matches!(
meta.name.as_str(),
"TicketMarkReady"
| "TicketQueue"
| "TicketIntakeReady"
| "CompleteTicket"
| "TicketWorkflowState"
| "TicketClose"
)));
if role == "role.orchestrator" {
assert!(prompt.contains("observed current `orchestrator_assignment_id`"));
assert!(prompt.contains(
"generic `WorkerSpawn` Ticket starts and existing-Worker assignment"
));
}
}
}
// Scripted policy/input fixtures: they do not run a model or certify its judgment.
#[test]
fn effective_return_wait_answer_and_close_fixtures_match_resolved_profile_tools_without_repo_or_mr()
{
let dir = tempfile::tempdir().unwrap();
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(dir.path())
.resolve(
&manifest::ProfileSelector::parse_cli("builtin:orchestrator"),
manifest::ProfileResolveOptions::with_worker_name("orchestrator-fixture"),
)
.unwrap();
let config = resolved.manifest.feature.ticket;
// Omit MR feature for the no-repository/no-MR work fixture; profile selection
// makes it available, not mandatory to exercise for every Ticket.
let (prompt, tools) = ticket_fixture_with_access(
&resolved.manifest.engine.instruction,
MergeRequestFeatureConfig::default(),
TicketFeatureAccess {
authoring: config.authoring,
thread: config.thread,
intake: config.intake,
workflow: config.workflow,
},
);
assert!(!tools.iter().any(|meta| matches!(
meta.name.as_str(),
"OpenMergeRequest" | "ReviewMergeRequest" | "CompleteMergeRequest"
)));
for (policy, name, input) in [
(
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
"TicketOrchestratorAssign",
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
),
(
"research returned to a human can be `open` + handling `done`",
"TicketAssignmentTrackingUpdate",
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"return-options", "tracking":{"status":"done", "result":"Options returned", "next_action":"Human decision"}}),
),
(
"Respect an explicit user approval condition",
"TicketAssignmentTrackingUpdate",
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"wait-for-answer", "tracking":{"status":"waiting", "waiting_reason":"User requested confirmation first", "next_action":"Read the user's answer on this assignment"}}),
),
(
"use `TicketAssignmentAnswer` with that `assignment_id`",
"TicketAssignmentAnswer",
serde_json::json!({"ticket":"T-743","assignment_id":"delegation","expected_last_event_id":"waiting","operation_key":"answer-on-same-row","body":"Option A approved; continue within scope"}),
),
(
"No Merge Request or approval is a prerequisite for a generic Ticket decision",
"TicketCaseUpdate",
serde_json::json!({"ticket":"T-743", "expected_content_digest":"observed-digest", "expected_state":"open", "expected_closure_operation_key":null, "operation_key":"fulfilled-close", "action":{"action":"close", "reason":"completed"}, "explanation":"The whole bounded request is fulfilled and closure was requested"}),
),
] {
assert!(prompt.contains(policy), "missing {policy}");
let meta = tools.iter().find(|meta| meta.name == name).unwrap();
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(validator.is_valid(&input), "{name} rejected {input}");
let required = meta.input_schema["required"].as_array().unwrap();
assert!(!required.iter().any(|field| matches!(
field.as_str(),
Some(
"repository_key"
| "merge_request_id"
| "approval_event_id"
| "workdir_attachments"
)
)));
let mut forged = input;
forged["actor"] = serde_json::json!("workspace-orchestrator");
assert!(
!validator.is_valid(&forged),
"model-supplied authority accepted"
);
}
}
+60 -2
View File
@@ -1,7 +1,9 @@
//! Exploration domain for immutable Session captures, with no Workspace/Memory mutation capability.
//!
//! A capture is an ordered projection of committed entries; exploration excludes reasoning and
//! system prompts. Overview, search, bounded reads, and evidence conversion share `SessionEntryRef`.
//! system prompts, including Host compaction summaries and other derived context. Full inherited
//! summary input belongs to compaction, not public observation or Memory evidence. Overview,
//! search, bounded reads, and evidence conversion share `SessionEntryRef`.
//! References identify entries in the selected Session and cannot be reused for another subject.
//! Appends preserve existing entry identities. Sparse real user/assistant anchors and intervening
//! entry counts index long histories. Search with an empty filter also provides bounded range lists.
@@ -734,7 +736,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
}
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary,
WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => {
SessionEntryProvenance::DerivedSummary
}
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
@@ -887,6 +891,60 @@ mod tests {
);
}
#[test]
fn host_summary_remains_private_to_compaction_not_observation_or_memory_evidence() {
let entries = [
(
Item::system_message("host-summary-secret"),
WorkerHistoryProvenance::CompactionSummary,
),
(
Item::system_message("other-derived-secret"),
WorkerHistoryProvenance::DerivedSummary,
),
(
Item::system_message("raw-system-secret"),
WorkerHistoryProvenance::LegacyUnknown,
),
(
Item::reasoning("reasoning-secret"),
WorkerHistoryProvenance::LegacyUnknown,
),
]
.into_iter()
.map(|(item, origin)| crate::session_history::history_entry(item, origin))
.collect::<Vec<_>>();
let refs = entries
.iter()
.map(|entry| SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id))
.collect::<Vec<_>>();
let capture = SessionCapture::from_history_entries("segment", entries);
assert!(capture.overview().is_empty());
assert!(
capture
.search(&SearchOptions {
query: "secret".into(),
..SearchOptions::default()
})
.is_empty()
);
assert!(
capture
.read(ReadSelector::EntryRange([0, 3]), ReadOptions::default())
.entries
.is_empty()
);
for reference in refs {
assert!(capture.evidence_for(reference.as_str()).is_none());
assert!(
capture
.read(ReadSelector::Id(reference.as_str()), ReadOptions::default())
.entries
.is_empty()
);
}
}
#[test]
fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new(
+104 -2
View File
@@ -17,6 +17,8 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
pub(crate) const COMPACTION_SUMMARY_HEADER: &str = "[Compacted context summary]\n\n";
#[cfg(test)]
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
LoggedHistoryEntry {
@@ -99,7 +101,10 @@ fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadat
}
/// Rebuild typed Worker history directly from the append-only Session Log.
/// Missing legacy metadata is never inferred from role or plaintext.
/// Missing metadata is not inferred from role or arbitrary plaintext. The one adopted legacy
/// Host seed is an opening `DerivedSummary` system item in an annotated compacted Segment,
/// bearing the exact Host summary marker. It is interpreted as `CompactionSummary` without
/// rewriting the durable record; later items and pasted markers never qualify.
pub(crate) fn restore_history_entries(
_session_id: SessionId,
_segment_id: SegmentId,
@@ -108,8 +113,32 @@ pub(crate) fn restore_history_entries(
let mut history = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
LogEntry::AnnotatedSegmentStart {
history: seed,
compacted_from,
..
} => {
history = seed.iter().map(from_logged).collect();
// Older Host compactions always put this DerivedSummary system item
// first in the replacement seed. Only that trusted construction boundary
// may interpret the marker; never infer origin from appended/pasted text.
if compacted_from.is_some()
&& let Some(first) = history.first_mut()
&& first.annotation.origin == WorkerHistoryProvenance::DerivedSummary
&& matches!(
&first.item,
Item::Message {
role: agen::Role::System,
..
}
)
&& first
.item
.as_text()
.is_some_and(|text| text.starts_with(COMPACTION_SUMMARY_HEADER))
{
first.annotation.origin = WorkerHistoryProvenance::CompactionSummary;
}
}
LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged))
@@ -144,6 +173,79 @@ mod tests {
use protocol::Segment;
use session_store::LogEntry;
#[test]
fn restore_recognizes_only_legacy_host_compaction_seed_summary() {
let summary = history_entry(
Item::system_message(format!("{COMPACTION_SUMMARY_HEADER}host summary")),
WorkerHistoryProvenance::DerivedSummary,
);
for compacted in [false, true] {
let seed = LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: SessionId::now_v7(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&summary)],
forked_from: None,
compacted_from: compacted.then(|| session_store::SegmentOrigin {
segment_id: SegmentId::now_v7(),
at_turn_index: 0,
}),
};
let appended = LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![],
extensions: vec![],
history: vec![to_logged_history_entry(&summary)],
};
let restored = restore_history_entries(
SessionId::now_v7(),
SegmentId::now_v7(),
&[seed, appended],
)
.unwrap();
assert_eq!(
restored[0].annotation.origin,
if compacted {
WorkerHistoryProvenance::CompactionSummary
} else {
WorkerHistoryProvenance::DerivedSummary
}
);
assert_eq!(
restored[1].annotation.origin,
WorkerHistoryProvenance::DerivedSummary
);
assert_eq!(restored[0].annotation.entry_id, summary.annotation.entry_id);
}
for item in [
Item::user_message(format!("{COMPACTION_SUMMARY_HEADER}pasted summary")),
Item::system_message("[Session TaskStore snapshot] tasks"),
] {
let seed = LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: SessionId::now_v7(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&history_entry(
item,
WorkerHistoryProvenance::DerivedSummary,
))],
forked_from: None,
compacted_from: Some(session_store::SegmentOrigin {
segment_id: SegmentId::now_v7(),
at_turn_index: 0,
}),
};
let restored =
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &[seed]).unwrap();
assert_eq!(
restored[0].annotation.origin,
WorkerHistoryProvenance::DerivedSummary
);
}
}
#[test]
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
let session_id = SessionId::now_v7();
+336 -20
View File
@@ -732,6 +732,28 @@ pub struct WorkspaceWorkerDiscoveryRequest {
#[derive(Debug, Clone)]
pub enum WorkspaceServerOperation {
TicketAssignmentSet {
ticket_id: String,
role: server_api::TicketAssignmentRole,
request: server_api::SetTicketRoleAssignmentRequest,
},
TicketAssignmentClear {
ticket_id: String,
role: server_api::TicketAssignmentRole,
query: server_api::ClearTicketRoleAssignmentQuery,
},
TicketAssignmentsPage {
ticket_id: String,
query: server_api::TicketAssignmentsQuery,
},
TicketAssignmentDiagnostics {
ticket_id: String,
query: server_api::TicketAssignmentDiagnosticsQuery,
},
TicketAssignmentAnswer {
ticket_id: String,
request: server_api::TicketAssignmentAnswerRequest,
},
WorkerControlList,
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
WorkerControlInput {
@@ -787,6 +809,45 @@ fn encode_workspace_path_segment(value: &str) -> String {
encoded
}
fn workspace_server_query_request<T: serde::Serialize>(
method: WorkspaceRequestMethod,
path: String,
query: &T,
) -> Result<WorkspaceRequest, WorkspaceClientError> {
let query = serde_json::to_value(query)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
let fields = query.as_object().ok_or_else(|| {
WorkspaceClientError::Request("Workspace query must serialize to an object".into())
})?;
let mut pairs = Vec::new();
for (name, value) in fields {
let value = match value {
serde_json::Value::Null => continue,
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Number(value) => value.to_string(),
_ => {
return Err(WorkspaceClientError::Request(
"Workspace query fields must be scalar".into(),
));
}
};
pairs.push(format!(
"{}={}",
encode_workspace_path_segment(name),
encode_workspace_path_segment(&value)
));
}
Ok(WorkspaceRequest {
method,
path: if pairs.is_empty() {
path
} else {
format!("{path}?{}", pairs.join("&"))
},
body: None,
})
}
fn workspace_server_operation_request(
workspace_id: &str,
operation: WorkspaceServerOperation,
@@ -794,6 +855,76 @@ fn workspace_server_operation_request(
let workspace_id = encode_workspace_path_segment(workspace_id);
let base = format!("/api/w/{workspace_id}");
match operation {
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id,
role,
request,
} => {
let role = match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
};
Ok(WorkspaceRequest::json(
WorkspaceRequestMethod::Put,
format!(
"{base}/tickets/{}/assignments/{role}",
encode_workspace_path_segment(&ticket_id)
),
serde_json::to_string(&request)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
}
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id,
role,
query,
} => {
let role = match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
};
workspace_server_query_request(
WorkspaceRequestMethod::Delete,
format!(
"{base}/tickets/{}/assignments/{role}",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-tracking/assignments",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-diagnostics",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => {
workspace_server_json_request(
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-answer",
encode_workspace_path_segment(&ticket_id)
),
&request,
)
}
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
"{base}/worker-control/workers"
))),
@@ -2804,6 +2935,7 @@ pub struct Worker<C: LlmClient, St: Store> {
/// manifest-derived lifecycle Features before controller startup.
manifest_lifecycle_features_enabled: bool,
interceptor_installed: bool,
compaction_service_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
/// Shared compaction state (present when threshold is configured).
compact_state: Option<Arc<CompactState>>,
/// Per-LLM-request Usage tracker. Always present after construction.
@@ -3148,6 +3280,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -4188,6 +4321,24 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.callback_socket.as_ref()
}
pub(crate) fn install_compaction_budget(
&mut self,
budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
) {
self.assert_hooks_open();
self.compaction_service_budget = budget.map(|budget| {
Arc::new(
budget.with_request_shape(
self.engine()
.get_system_prompt()
.unwrap_or_default()
.to_owned(),
self.engine().tool_server_handle(),
),
)
});
}
// --- Hook registration ---
fn assert_hooks_open(&self) {
@@ -4283,6 +4434,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.log_writer.clone(),
self.pending_committed_history.clone(),
)
.with_compaction_budget(self.compaction_service_budget.clone())
.with_notification_committer(self.notification_committer.clone())
.with_usage_tracker(self.usage_tracker.clone())
.with_metrics_tracker(self.metrics_tracker.clone())
@@ -6603,7 +6755,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let history_entries = self.session.history().entries();
let retain_from = cut.index.min(history_entries.len());
let retained_history_entries = history_entries[retain_from..].to_vec();
let initial_summary = initial_compaction_summary(history_entries)?;
// The old summary is consumed in full by the compaction service even when
// the cut falls before it. The replacement summary supersedes it: retaining
// it as well would duplicate inherited context on every compact.
let retained_history_entries = history_entries[retain_from..]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.cloned()
.collect::<Vec<_>>();
let mut retained_items = retained_history_entries
.iter()
.map(|entry| entry.item.clone())
@@ -6616,7 +6776,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
project_compaction_attachments(&mut retained_items, default_workdir_alias.as_deref());
// The compactor explores summaries, text details, and ViewImage call arguments. It
// never needs durable image bodies; relevant images are nominated with add_reference.
let mut entries_to_summarise = history_entries[..retain_from].to_vec();
let mut entries_to_summarise = history_entries[..retain_from]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.cloned()
.collect::<Vec<_>>();
project_compactor_capture_attachments(&mut entries_to_summarise);
let items_to_summarise = entries_to_summarise
.iter()
@@ -6681,11 +6845,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
Vec::new()
};
// Input text fed to the compact worker. Includes the default
// references, current TaskStore snapshot, current TaskStore snapshot, and the (pruned) conversation text.
// The full Host summary is mandatory input, separate from the bounded
// prefix overview and current TaskStore snapshot.
let task_snapshot_text = self.task_feature.snapshot_text();
let summary_input = build_summary_input(
&items_to_summarise,
initial_summary.as_deref(),
&default_refs,
Some(task_snapshot_text.as_str()),
SummaryInputOptions {
@@ -6797,12 +6962,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
input: summary_input.text.clone(),
cache_key: Some(self.segment_id().to_string()),
max_turns: worker_max_turns,
compaction_budget: Some(interceptor),
engine_configurator: Some(Box::new(move |engine| {
let tracker = tracker_for_engine;
engine.on_usage(move |event| {
tracker.record_usage(event);
});
engine.set_interceptor(interceptor);
})),
features,
required_tools,
@@ -6862,7 +7027,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
Some(
"You have not called `write_summary` yet. Deliver the structured \
summary now (Completed Tasks / Active Task / Key Decisions / \
User Directives / Current Work) and nominate any files the next \
User Directives / Current Work), preserving ongoing goals and user \
directives from the initial summary and segment. Nominate any files the next \
session needs with `mark_read_required` for UTF-8 text or \
`add_reference` for images, binary files, and on-demand reads."
.to_string(),
@@ -6894,11 +7060,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.ok_or(WorkerError::CompactSummaryMissing)?;
let mut summary_tokens = estimate_text_tokens(summary_text.len());
if summary_max_tokens > 0 && summary_tokens > summary_max_tokens {
let prompt = format!(
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
);
let prompt =
summary_shrink_prompt(summary_tokens, summary_max_tokens, summary_target_tokens);
handle
.send(prompt)
.await
@@ -7007,8 +7170,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
);
let mut compact_introduced_system_messages =
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
let summary_message =
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
let summary_message = Item::system_message(format!(
"{}{summary_text}",
crate::session_history::COMPACTION_SUMMARY_HEADER
));
compact_introduced_system_messages.push(summary_message.clone());
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
if let Some(msg) = reference_message.as_ref() {
@@ -7065,8 +7230,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let compacted_history_entries = new_history
.iter()
.cloned()
.map(|item| {
if let Some((offset, original)) = original_entries[original_cursor..]
.enumerate()
.map(|(index, item)| {
if index == 0 {
HistoryEntry::new(
item,
new_history_metadata(
WorkerHistoryProvenance::CompactionSummary,
Some(SessionHistoryDerivation {
sources: derived_sources.clone(),
}),
),
)
} else if let Some((offset, original)) = original_entries[original_cursor..]
.iter()
.enumerate()
.find(|(_, original)| original.item == item)
@@ -7482,6 +7658,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -7821,6 +7998,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: false,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -7950,6 +8128,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -8335,6 +8514,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -8733,6 +8913,61 @@ fn build_related_file_message(
)))
}
fn summary_shrink_prompt(
summary_tokens: u64,
summary_max_tokens: u64,
summary_target_tokens: u64,
) -> String {
format!(
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
same five sections. Compress duplicate explanations and old work details \
first; preserve ongoing goals and user directives from the initial summary \
and segment, including scope, prohibitions, completion conditions and \
exception-reporting duties. Apply explicit later changes/retractions and \
retain uncertainty; do not replace the original request with recovery \
procedures or recent progress. Target ≈{summary_target_tokens} tokens."
)
}
/// Select only the Host's typed summary, never a marker in ordinary content.
/// Invalid/duplicate Host summaries are a broken seed contract, not permission
/// to silently omit inherited instructions.
fn initial_compaction_summary(
entries: &[HistoryEntry<SessionHistoryMetadata>],
) -> Result<Option<String>, WorkerError> {
let mut summaries = entries
.iter()
.filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::CompactionSummary);
let Some(entry) = summaries.next() else {
return Ok(None);
};
if summaries.next().is_some() {
return Err(WorkerError::InvalidState(
"multiple Host compaction summaries in active history".into(),
));
}
if !matches!(
&entry.item,
Item::Message {
role: agen::Role::System,
..
}
) {
return Err(WorkerError::InvalidState(
"Host compaction summary must be a system message".into(),
));
}
let body = entry
.item
.as_text()
.and_then(|text| text.strip_prefix(crate::session_history::COMPACTION_SUMMARY_HEADER))
.ok_or_else(|| {
WorkerError::InvalidState("Host compaction summary header is missing".into())
})?;
Ok(Some(body.to_string()))
}
#[derive(Debug, Clone, Copy)]
struct SummaryInputOptions {
overview_target_tokens: u64,
@@ -8749,11 +8984,12 @@ struct SummaryInputBuild {
deadline_fallback_used: bool,
}
/// Build the compact worker's input: default-reference instructions,
/// the list of recently-touched files, task snapshot,
/// and a bounded overview rather than a prefix-wide transcript.
/// Build mandatory full Host-summary input plus a bounded prefix overview,
/// recently-touched files and current tasks. Overview budgets never apply to
/// the summary; the complete request is checked by the compaction interceptor.
fn build_summary_input(
items: &[Item],
initial_summary: Option<&str>,
default_refs: &[PathBuf],
task_snapshot: Option<&str>,
options: SummaryInputOptions,
@@ -8779,10 +9015,20 @@ fn build_summary_input(
out.push_str(&format!(
"Summarise this session into a structured summary of about {} tokens and \
nominate files the next session needs. The conversation below is a \
bounded overview/index, not the full transcript. Use tools to inspect \
bounded overview/index, not the full transcript. A separately supplied initial \
compaction summary is complete, mandatory source material. Preserve ongoing \
user goals and directives from it and from this segment's user messages; \
confirm important wording with exploration rather than reconstructing the \
request from recent progress. Retained tail entries remain verbatim in the \
next session and are outside this prefix overview. Use tools to inspect \
current files when deciding auto-read/reference output.\n\n",
options.summary_target_tokens
));
if let Some(summary) = initial_summary {
out.push_str("## Initial compaction summary (full text)\n");
out.push_str(summary);
out.push_str("\n\n## End initial compaction summary\n\n");
}
if !default_refs.is_empty() {
out.push_str(
"These files were touched recently in this session. Use `read_file` \
@@ -8801,7 +9047,8 @@ fn build_summary_input(
out.push_str(
"## Current Session TaskStore\n\
This is the full current task list. Use it as source material for the \
summary, especially active (pending/inprogress) tasks, but do not edit tasks \
summary, especially active (pending/inprogress) tasks, but not as a \
replacement for user goals, directives or completion conditions. Do not edit tasks \
from the compact worker.\n",
);
out.push_str(task_snapshot);
@@ -10081,6 +10328,7 @@ mod build_summary_prompt_tests {
fn test_summary_input(items: &[Item]) -> String {
build_summary_input(
items,
None,
&[],
None,
SummaryInputOptions {
@@ -10093,6 +10341,72 @@ mod build_summary_prompt_tests {
.text
}
#[test]
fn compact_prompt_and_shrink_policy_prioritize_continuing_user_intent() {
// The builtin resource is the default model-visible compaction contract.
let prompt = include_str!("../../../resources/prompts/internal/compact_system.md");
for policy in [
"initial compaction summary in full",
"segment's user messages",
"prohibitions",
"completion conditions",
"explicit changes or retractions",
"Preserve uncertainty",
"retained tail stays verbatim",
"TaskStore",
] {
assert!(prompt.contains(policy), "missing prompt policy: {policy}");
}
let shrink = summary_shrink_prompt(2_000, 1_000, 500);
for policy in [
"initial summary",
"and segment",
"Compress duplicate",
"prohibitions",
"completion conditions",
"exception-reporting duties",
"changes/retractions",
"uncertainty",
"recovery",
] {
assert!(shrink.contains(policy), "missing shrink policy: {policy}");
}
let input = test_summary_input(&[Item::user_message("keep original scope")]);
assert!(input.contains("Preserve ongoing user goals and directives"));
assert!(input.contains("Retained tail entries remain verbatim"));
}
#[test]
fn malformed_or_duplicate_host_summaries_fail_instead_of_omitting_context() {
use crate::session_history::history_entry;
let valid = history_entry(
Item::system_message(format!(
"{}body",
crate::session_history::COMPACTION_SUMMARY_HEADER
)),
WorkerHistoryProvenance::CompactionSummary,
);
assert_eq!(
initial_compaction_summary(std::slice::from_ref(&valid))
.unwrap()
.as_deref(),
Some("body")
);
assert!(initial_compaction_summary(&[valid.clone(), valid]).is_err());
for item in [
Item::user_message("pasted"),
Item::system_message("missing marker"),
] {
assert!(
initial_compaction_summary(&[history_entry(
item,
WorkerHistoryProvenance::CompactionSummary
)])
.is_err()
);
}
}
#[test]
fn retained_images_become_on_demand_related_files() {
let mut items = vec![
@@ -10193,6 +10507,7 @@ mod build_summary_prompt_tests {
let items = vec![Item::user_message("x".repeat(4_000))];
let built = build_summary_input(
&items,
None,
&[],
None,
SummaryInputOptions {
@@ -10212,6 +10527,7 @@ mod build_summary_prompt_tests {
let items = vec![Item::user_message("x".repeat(4_000))];
let built = build_summary_input(
&items,
None,
&[],
None,
SummaryInputOptions {
+737 -1
View File
@@ -133,6 +133,7 @@ struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
call_count: Arc<AtomicUsize>,
observer: Arc<Mutex<Option<CallObserver>>>,
requests: Arc<Mutex<Vec<Request>>>,
}
impl MockClient {
@@ -141,9 +142,14 @@ impl MockClient {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
observer: Arc::new(Mutex::new(None)),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
fn set_observer(&self, observer: impl Fn(usize) + Send + Sync + 'static) {
*self.observer.lock().unwrap() = Some(Arc::new(observer));
}
@@ -157,9 +163,10 @@ impl LlmClient for MockClient {
async fn stream(
&self,
_request: Request,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.requests.lock().unwrap().push(request);
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if let Some(observer) = self.observer.lock().unwrap().clone() {
observer(count);
@@ -2065,3 +2072,732 @@ async fn controller_compact_method_publishes_progress_and_clear() {
let command = protocol::WorkerCommandEnvelope::new(2);
let _ = handle.send(Method::Shutdown { command }).await;
}
const INITIAL_SUMMARY_HEADING: &str = "## Initial compaction summary (full text)\n";
const OVERVIEW_HEADING: &str = "## Conversation overview/index\n";
const SUMMARY_MARKER: &str = "[Compacted context summary]\n\n";
// These tests prove transport and durable replacement policy, not whether a live
// model preserves meaning. All write_summary outputs below are predetermined.
struct SummaryInputFixture {
worker: Worker<MockClient, TestStore>,
_store_dir: tempfile::TempDir,
}
impl SummaryInputFixture {
async fn new(manifest_toml: &str, client: MockClient) -> Self {
let store_dir = tempfile::tempdir().unwrap();
let store = CombinedStore::new(
FsStore::new(store_dir.path()).unwrap(),
FsWorkerStore::new(store_dir.path().join("pods")).unwrap(),
);
let engine =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
client,
);
let mut worker = Worker::new(
worker::WorkerManifest::from_toml(manifest_toml).unwrap(),
engine,
store,
worker::WorkerWorkspaceContext::no_workspace(),
worker::WorkerFilesystemAuthority::None,
worker::Scope::empty(),
)
.await
.unwrap();
worker.enable_worker_metadata_write_through().unwrap();
worker.attach_log_writer(Arc::new(worker.log_writer_handle()));
Self {
worker,
_store_dir: store_dir,
}
}
// Seed the persisted boundary, then exercise the public rewind API's shared
// typed history restore path. No production client or global allocation is
// needed. This is restoration coverage, not a cold Runtime restart test.
async fn restore_seed(
&mut self,
history: Vec<session_store::LoggedHistoryEntry>,
compacted: bool,
) {
self.worker
.run_text("fixture rewind boundary")
.await
.unwrap();
let session_id = self.worker.session_id();
let segment_id = self.worker.segment_id();
let mut records = self
.worker
.store()
.read_all(session_id, segment_id)
.unwrap();
let start = records
.iter_mut()
.find(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. }))
.unwrap();
match start {
LogEntry::AnnotatedSegmentStart {
history: seed,
compacted_from,
..
} => {
*seed = history;
*compacted_from = compacted.then(|| session_store::SegmentOrigin {
segment_id: session_store::new_segment_id(),
at_turn_index: 0,
});
}
_ => unreachable!(),
}
self.worker
.store()
.create_segment(session_id, segment_id, &records)
.unwrap();
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&records);
let target = snapshot
.entries
.iter()
.rev()
.find(|entry| {
matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
)
})
.expect("fixture rewind boundary must remain a public user input");
self.worker
.rewind_to(target.entry_id.clone())
.await
.unwrap();
}
}
fn summary_entry(
item: Item,
origin: session_store::LoggedSessionHistoryOrigin,
) -> session_store::LoggedHistoryEntry {
let mut entry = annotated(item);
entry.metadata.origin = origin;
entry
}
fn long_summary(label: &str) -> String {
format!(
"## Completed Tasks\n{label}: investigated the transport boundary.\n\n\
## Active Task\nKeep the original user's completion conditions.\n{}\n\
## Key Decisions\nDo not infer provenance from pasted markers.\n\n\
## User Directives\n{label}_LOAD_BEARING_SENTINEL_AT_END\n\n\
## Current Work\nContinue the requested implementation.",
"Historical constraints must remain explicit, including the original acceptance criteria.\n"
.repeat(70)
)
}
fn recent_history(count: usize) -> Vec<session_store::LoggedHistoryEntry> {
(0..count)
.flat_map(|index| {
[
annotated(Item::user_message(format!(
"recent-user-{index}: {}",
"recent progress is not a replacement for the original directive. ".repeat(40)
))),
annotated(Item::assistant_message(format!("recent-assistant-{index}"))),
]
})
.collect()
}
fn first_compactor_input(request: &Request) -> String {
request
.items
.iter()
.find_map(|item| match item {
Item::Message {
role: agen::Role::User,
..
} => item
.as_text()
.filter(|text| text.contains(OVERVIEW_HEADING)),
_ => None,
})
.expect("the first compactor request must carry its overview input")
.to_string()
}
fn assert_full_initial_summary(input: &str, summary: &str) {
let heading_at = input
.find(INITIAL_SUMMARY_HEADING)
.expect("full summary heading");
let overview_at = input.find(OVERVIEW_HEADING).expect("overview heading");
let summary_at = heading_at + INITIAL_SUMMARY_HEADING.len();
assert!(summary_at < overview_at);
assert!(
input[summary_at..].starts_with(summary),
"summary must be verbatim"
);
assert_eq!(
input.matches(summary).count(),
1,
"summary must not be duplicated"
);
assert!(!input[overview_at..].contains(summary));
let opening = summary.chars().take(100).collect::<String>();
assert!(
!input[overview_at..].contains(&opening),
"overview must not carry even a clipped summary"
);
assert!(!input[overview_at..].contains("LOAD_BEARING_SENTINEL_AT_END"));
}
fn replacement_seed(
worker: &Worker<MockClient, TestStore>,
) -> Vec<session_store::LoggedHistoryEntry> {
worker
.store()
.read_all(worker.session_id(), worker.segment_id())
.unwrap()
.into_iter()
.find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => Some(history),
_ => None,
})
.expect("replacement SegmentStart")
}
#[tokio::test]
async fn full_initial_summary_survives_small_overview_budgets_and_both_retention_extremes() {
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let inherited = long_summary("original");
assert!(inherited.chars().count() > 4_000);
for retained in [0, 1_000_000] {
for (warning, deadline) in [(0, 0), (1, 0), (1, 1)] {
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
&format!(
"[compaction]\noverview_target_tokens = 1\noverview_warning_tokens = {warning}\noverview_deadline_tokens = {deadline}"
),
);
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "new summary"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
let mut seed = vec![summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
CompactionSummary,
)];
let recent = recent_history(40);
seed.extend(recent.clone());
fixture.restore_seed(seed, true).await;
let (tx, mut rx) = broadcast::channel::<Event>(64);
fixture.worker.attach_alerter(worker::Alerter::new(tx));
fixture.worker.compact(retained).await.unwrap();
let requests = observed.requests();
assert_eq!(requests.len(), 3);
let input = first_compactor_input(&requests[1]);
assert_full_initial_summary(&input, &inherited);
let alerts = drain(&mut rx);
let has_alert = |snippet: &str| {
alerts.iter().any(|event| {
matches!(event, Event::Alert(alert)
if alert.source == protocol::AlertSource::Compactor
&& alert.level == protocol::AlertLevel::Warn
&& alert.message.contains(snippet))
})
};
assert_eq!(has_alert("larger than expected"), warning != 0);
assert_eq!(has_alert("using coarse fallback"), deadline != 0);
let replacement = replacement_seed(&fixture.worker);
assert_eq!(
replacement
.iter()
.filter(|entry| entry.metadata.origin == CompactionSummary)
.count(),
1
);
assert!(replacement.iter().all(|entry| {
!Item::from(entry.item.clone())
.as_text()
.is_some_and(|text| text.contains(&inherited))
}));
for entry in &recent {
assert_eq!(
replacement
.iter()
.any(|retained_entry| retained_entry == entry),
retained != 0,
"ordinary retained entries must keep their item and metadata"
);
}
let metrics = session_metrics::read_session_metrics(
fixture.worker.store(),
fixture.worker.session_id(),
)
.unwrap();
let overview_tokens = metrics
.iter()
.find(|record| record.metric.name == "compact.overview_tokens")
.and_then(|record| record.metric.value)
.unwrap();
assert!(
overview_tokens < inherited.len().div_ceil(4) as f64,
"overview accounting must exclude the inherited full summary"
);
}
}
}
#[tokio::test]
async fn pasted_markers_and_other_derived_context_remain_ordinary_overview_material() {
use session_store::LoggedSessionHistoryOrigin::{
CompactionSummary, DerivedSummary, LegacyUnknown,
};
for retained in [0, 1_000_000] {
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
let ordinary = vec![
summary_entry(
Item::system_message("[Session TaskStore snapshot]\nactive task"),
DerivedSummary,
),
summary_entry(
Item::system_message("auto-read file source material"),
DerivedSummary,
),
summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}pasted system marker")),
LegacyUnknown,
),
summary_entry(
Item::user_message(format!("{SUMMARY_MARKER}pasted user marker")),
LegacyUnknown,
),
summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}later derived marker")),
DerivedSummary,
),
];
fixture.restore_seed(ordinary.clone(), true).await;
assert!(
fixture
.worker
.session_history()
.entries()
.iter()
.all(|entry| entry.annotation.origin != CompactionSummary)
);
fixture.worker.compact(retained).await.unwrap();
let input = first_compactor_input(&observed.requests()[1]);
assert!(!input.contains(INITIAL_SUMMARY_HEADING));
let overview = input.split_once(OVERVIEW_HEADING).unwrap().1;
let replacement = replacement_seed(&fixture.worker);
for entry in &ordinary {
let text = Item::from(entry.item.clone())
.as_text()
.unwrap()
.to_string();
assert_eq!(
overview.contains(&text),
retained == 0,
"ordinary overview source: {text}"
);
assert_eq!(
replacement
.iter()
.any(|retained_entry| retained_entry == entry),
retained != 0,
"other derived context and pasted markers must follow ordinary retention"
);
}
}
}
#[tokio::test]
async fn restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker()
{
use session_store::LoggedSessionHistoryOrigin::{
CompactionSummary, DerivedSummary, LegacyUnknown,
};
let inherited = long_summary("legacy");
// Each false case lacks one trusted construction condition; marker text
// alone must never promote ordinary context into inherited Host material.
for case in [
"trusted",
"no-lineage",
"not-first",
"unknown-origin",
"user-role",
"no-marker",
] {
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
let text = if case == "no-marker" {
inherited.clone()
} else {
format!("{SUMMARY_MARKER}{inherited}")
};
let item = if case == "user-role" {
Item::user_message(text)
} else {
Item::system_message(text)
};
let origin = if case == "unknown-origin" {
LegacyUnknown
} else {
DerivedSummary
};
let candidate = summary_entry(item, origin);
let candidate_id = candidate.metadata.entry_id.clone();
let mut history = vec![candidate];
if case == "not-first" {
history.insert(
0,
annotated(Item::system_message("unrelated first system context")),
);
}
history.extend(recent_history(20));
fixture.restore_seed(history, case != "no-lineage").await;
let restored = fixture
.worker
.session_history()
.entries()
.iter()
.find(|entry| entry.annotation.entry_id == candidate_id)
.unwrap();
assert_eq!(
restored.annotation.origin == CompactionSummary,
case == "trusted",
"{case}"
);
// A restore-time upgrade is an in-memory interpretation of the trusted
// seed; it must not silently rewrite the historical durable record.
assert!(
replacement_seed(&fixture.worker)
.iter()
.all(|entry| entry.metadata.origin != CompactionSummary)
);
fixture.worker.compact(0).await.unwrap();
let input = first_compactor_input(&observed.requests()[1]);
if case == "trusted" {
assert_full_initial_summary(&input, &inherited);
} else {
assert!(!input.contains(INITIAL_SUMMARY_HEADING), "{case}");
}
}
}
#[tokio::test]
async fn three_chained_compactions_transport_each_complete_predecessor_without_retaining_old_summaries()
{
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let original_goal =
"CHAIN_ORIGINAL_USER_GOAL: finish only after all explicit acceptance conditions hold";
// Every predetermined output keeps the original directive. The assertions
// prove that complete predecessors reach later requests, not that a model
// chose to preserve this directive on its own.
let summaries = (0..3)
.map(|index| {
format!(
"{}\n{original_goal}",
long_summary(&format!("generation-{index}"))
)
})
.collect::<Vec<_>>();
let initial = format!("{}\n{original_goal}", long_summary("initial"));
let mut responses = vec![single_text_events("seed response")];
for (index, summary) in summaries.iter().enumerate() {
responses.push(single_text_events(
"acknowledge new directive and temporary recovery",
));
responses.push(write_summary_tool_use_events(
&format!("summary-{index}"),
summary,
));
responses.push(single_text_events("done"));
}
let client = MockClient::new(responses);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
fixture
.restore_seed(
vec![summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{initial}")),
CompactionSummary,
)],
true,
)
.await;
let original_session = fixture.worker.session_id();
let mut predecessor = fixture.worker.segment_id();
for (index, summary) in summaries.iter().enumerate() {
let new_directive = format!(
"NEW_USER_DIRECTIVE_{index}: do not merge. Temporarily recover the interrupted work and collect SubWorkers; this does not retract the original goal."
);
fixture.worker.run_text(&new_directive).await.unwrap();
let first_request = observed.requests().len();
fixture.worker.compact(0).await.unwrap();
let input = first_compactor_input(&observed.requests()[first_request]);
let prior = if index == 0 {
&initial
} else {
&summaries[index - 1]
};
assert_full_initial_summary(&input, prior);
assert!(input.contains(original_goal));
assert!(input.contains(&new_directive));
let seed = replacement_seed(&fixture.worker);
let inherited = seed
.iter()
.filter(|entry| entry.metadata.origin == CompactionSummary)
.collect::<Vec<_>>();
assert_eq!(inherited.len(), 1);
assert_eq!(
Item::from(inherited[0].item.clone()).as_text().unwrap(),
format!("{SUMMARY_MARKER}{summary}")
);
for old in &summaries[..index] {
assert!(
fixture
.worker
.history()
.iter()
.all(|item| !item.as_text().is_some_and(|text| text.contains(old)))
);
}
let records = fixture
.worker
.store()
.read_all(original_session, fixture.worker.segment_id())
.unwrap();
assert!(
records
.iter()
.any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart {
compacted_from: Some(origin), ..
} if origin.segment_id == predecessor))
);
let restored = session_store::restore(
fixture.worker.store(),
original_session,
fixture.worker.segment_id(),
)
.unwrap();
assert!(restored.history.iter().any(|item| {
item.as_text()
.is_some_and(|text| text == format!("{SUMMARY_MARKER}{summary}"))
}));
predecessor = fixture.worker.segment_id();
}
assert_eq!(observed.requests().len(), 10);
}
#[tokio::test]
async fn inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate() {
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
"[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 0\nfinal_reserve_tokens = 0\noverview_target_tokens = 1",
);
for include_summary in [false, true] {
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
let summary =
"Preserve the user's goal and every explicit completion condition.\n".repeat(3_000);
assert!(
agen::token_counter::total_tokens(&[Item::user_message(&summary)], &[]).tokens > 10_000
);
let mut history = Vec::new();
if include_summary {
history.push(summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{summary}")),
CompactionSummary,
));
}
history.extend(recent_history(20));
fixture.restore_seed(history, true).await;
let source = fixture.worker.segment_id();
let source_history = fixture.worker.history();
let result = fixture.worker.compact(0).await;
if include_summary {
let error = result.expect_err("mandatory input over hard cap must fail, not truncate");
assert!(
matches!(error, WorkerError::CompactCancelled)
|| error.to_string().contains("input occupancy"),
"unexpected hard-cap failure: {error:?}"
);
assert_eq!(
observed.requests().len(),
1,
"no compactor request may cross the hard cap"
);
assert_eq!(fixture.worker.segment_id(), source);
assert_eq!(fixture.worker.history(), source_history);
assert_eq!(
fixture
.worker
.store()
.read_by_name("test-worker")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(source)
);
assert_eq!(
fixture
.worker
.store()
.list_segments(fixture.worker.session_id())
.unwrap()
.len(),
1
);
} else {
result.expect("the same bounded overview without inherited input fits the cap");
assert_eq!(observed.requests().len(), 3);
assert_ne!(fixture.worker.segment_id(), source);
}
}
}
#[tokio::test]
async fn summary_output_still_over_hard_max_after_rewrite_fails_without_activation() {
let oversized = long_summary("oversized");
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
"[compaction]\nsummary_target_tokens = 50\nsummary_max_tokens = 100",
);
let client = MockClient::new(vec![
single_text_events("seed"),
write_summary_tool_use_events("first-output", &oversized),
single_text_events("done"),
write_summary_tool_use_events("rewrite-output", &oversized),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
fixture.worker.run_text("user goal").await.unwrap();
let source = fixture.worker.segment_id();
let history = fixture.worker.history();
let error = fixture.worker.compact(0).await.unwrap_err();
assert!(matches!(
error,
WorkerError::CompactSummaryTooLarge { max: 100, .. }
));
assert_eq!(fixture.worker.segment_id(), source);
assert_eq!(fixture.worker.history(), history);
assert_eq!(
fixture
.worker
.store()
.read_by_name("test-worker")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(source)
);
assert_eq!(
fixture
.worker
.store()
.list_segments(fixture.worker.session_id())
.unwrap()
.len(),
1
);
assert!(
observed
.requests()
.iter()
.any(|request| request.items.iter().any(|item| item
.as_text()
.is_some_and(|text| text.contains("Rewrite it now with `write_summary`"))))
);
}
#[tokio::test]
async fn budget_warning_is_durable_and_keeps_full_summary_in_first_model_request() {
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
"[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 9999\nfinal_reserve_tokens = 9999",
);
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("summary", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
let inherited = long_summary("budget-warning");
fixture
.restore_seed(
vec![summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
CompactionSummary,
)],
true,
)
.await;
let (tx, mut rx) = broadcast::channel::<Event>(128);
fixture.worker.attach_alerter(worker::Alerter::new(tx));
fixture.worker.compact(0).await.unwrap();
let requests = observed.requests();
assert_full_initial_summary(&first_compactor_input(&requests[1]), &inherited);
let warning = requests[1]
.items
.iter()
.filter_map(Item::as_text)
.find(|text| text.starts_with("[Compact worker budget warning]"))
.expect("normal internal Worker must preserve the budget interceptor warning");
assert!(warning.contains("full initial summary and segment"));
assert!(warning.contains("changes/retractions"));
assert!(
requests[2]
.items
.iter()
.filter_map(Item::as_text)
.any(|text| text == warning),
"warning must remain in history, not be a transient request insertion"
);
assert!(
drain(&mut rx)
.iter()
.any(|event| matches!(event, Event::Alert(alert)
if alert.message.contains("compact worker context budget is low")))
);
}