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:
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user