diff options
Diffstat (limited to 'crates/phone-opus')
| -rw-r--r-- | crates/phone-opus/src/mcp/fault.rs | 82 | ||||
| -rw-r--r-- | crates/phone-opus/src/mcp/service.rs | 67 | ||||
| -rw-r--r-- | crates/phone-opus/tests/mcp_hardening.rs | 118 |
3 files changed, 262 insertions, 5 deletions
diff --git a/crates/phone-opus/src/mcp/fault.rs b/crates/phone-opus/src/mcp/fault.rs index 5b23f79..4d438e3 100644 --- a/crates/phone-opus/src/mcp/fault.rs +++ b/crates/phone-opus/src/mcp/fault.rs @@ -1,7 +1,33 @@ +use std::collections::BTreeMap; + use libmcp::{Fault, FaultClass, FaultCode, Generation, RecoveryDirective, ToolErrorDetail}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct FaultContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) consult: Option<ConsultFaultContext>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub(crate) struct ConsultFaultContext { + pub(crate) cwd: String, + pub(crate) context_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) reused_session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) downstream_session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) resume_session_id: Option<String>, + #[serde(default, skip_serializing_if = "is_false")] + pub(crate) quota_limited: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) quota_reset_hint: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) retry_hint: Option<String>, +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] pub(crate) enum FaultStage { Host, @@ -18,6 +44,8 @@ pub(crate) struct FaultRecord { pub(crate) stage: FaultStage, pub(crate) operation: String, pub(crate) jsonrpc_code: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) context: Option<Box<FaultContext>>, pub(crate) retryable: bool, pub(crate) retried: bool, } @@ -153,15 +181,59 @@ impl FaultRecord { self } + pub(crate) fn with_context(mut self, context: FaultContext) -> Self { + self.context = Some(Box::new(context)); + self + } + pub(crate) fn message(&self) -> &str { self.fault.detail.as_str() } + fn rendered_message(&self) -> String { + let mut lines = vec![self.message().to_owned()]; + let Some(consult) = self + .context + .as_ref() + .and_then(|context| context.consult.as_ref()) + else { + return lines.join("\n"); + }; + let mut fields: BTreeMap<String, String> = BTreeMap::from([ + ("cwd".to_owned(), consult.cwd.clone()), + ("context_mode".to_owned(), consult.context_mode.clone()), + ]); + if let Some(session_id) = consult.reused_session_id.as_ref() { + let _ = fields.insert("reused_session".to_owned(), session_id.clone()); + } + if let Some(session_id) = consult.downstream_session_id.as_ref() { + let _ = fields.insert("downstream_session".to_owned(), session_id.clone()); + } + if let Some(session_id) = consult.resume_session_id.as_ref() { + let _ = fields.insert("resume_session".to_owned(), session_id.clone()); + } + if consult.quota_limited { + let _ = fields.insert("quota_limited".to_owned(), "true".to_owned()); + } + if let Some(reset_hint) = consult.quota_reset_hint.as_ref() { + let _ = fields.insert("quota_reset".to_owned(), reset_hint.clone()); + } + if let Some(retry_hint) = consult.retry_hint.as_ref() { + let _ = fields.insert("retry_hint".to_owned(), retry_hint.clone()); + } + lines.extend( + fields + .into_iter() + .map(|(key, value)| format!("{key}: {value}")), + ); + lines.join("\n") + } + pub(crate) fn error_detail(&self) -> ToolErrorDetail { ToolErrorDetail { code: Some(self.jsonrpc_code), kind: Some(self.fault.code.as_str().to_owned()), - message: Some(self.message().to_owned()), + message: Some(self.rendered_message()), } } @@ -174,10 +246,11 @@ impl FaultRecord { } pub(crate) fn into_tool_result(self) -> Value { + let rendered_message = self.rendered_message(); json!({ "content": [{ "type": "text", - "text": self.message(), + "text": rendered_message, }], "structuredContent": self, "isError": true, @@ -201,11 +274,16 @@ impl FaultRecord { stage, operation: operation.into(), jsonrpc_code, + context: None, retried: false, } } } +const fn is_false(value: &bool) -> bool { + !*value +} + fn fault_code(code: &'static str) -> FaultCode { match FaultCode::try_new(code.to_owned()) { Ok(value) => value, diff --git a/crates/phone-opus/src/mcp/service.rs b/crates/phone-opus/src/mcp/service.rs index 993a0e4..c5c2d66 100644 --- a/crates/phone-opus/src/mcp/service.rs +++ b/crates/phone-opus/src/mcp/service.rs @@ -16,7 +16,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use users::get_current_uid; use uuid::Uuid; -use crate::mcp::fault::{FaultRecord, FaultStage}; +use crate::mcp::fault::{ConsultFaultContext, FaultContext, FaultRecord, FaultStage}; use crate::mcp::output::{ ToolOutput, fallback_detailed_tool_output, split_presentation, tool_success, }; @@ -89,7 +89,7 @@ impl WorkerService { let request = ConsultRequest::parse(args) .map_err(|error| invalid_consult_request(self.generation, &operation, error))?; let response = invoke_claude(&request) - .map_err(|error| consult_fault(self.generation, &operation, error))?; + .map_err(|error| consult_fault(self.generation, &operation, &request, error))?; consult_output(&request, &response, self.generation, &operation)? } other => { @@ -844,9 +844,11 @@ fn invalid_consult_request( fn consult_fault( generation: Generation, operation: &str, + request: &ConsultRequest, error: ConsultInvocationError, ) -> FaultRecord { - match error { + let context = consult_fault_context(request, &error); + let record = match error { ConsultInvocationError::Spawn(source) => FaultRecord::process( generation, FaultStage::Claude, @@ -857,7 +859,66 @@ fn consult_fault( | ConsultInvocationError::Downstream(detail) => { FaultRecord::downstream(generation, FaultStage::Claude, operation, detail) } + }; + record.with_context(context) +} + +fn consult_fault_context(request: &ConsultRequest, error: &ConsultInvocationError) -> FaultContext { + let detail = match error { + ConsultInvocationError::Spawn(_) => None, + ConsultInvocationError::InvalidJson(detail) + | ConsultInvocationError::Downstream(detail) => Some(detail.as_str()), + }; + let reused_session_id = request.reused_session_id(); + let downstream_session_id = detail.and_then(downstream_session_id); + let resume_session_id = downstream_session_id + .clone() + .or_else(|| reused_session_id.clone()); + let quota_reset_hint = detail.and_then(quota_reset_hint); + let quota_limited = quota_reset_hint.is_some(); + let retry_hint = consult_retry_hint(quota_limited, resume_session_id.as_deref()); + FaultContext { + consult: Some(ConsultFaultContext { + cwd: request.cwd.display(), + context_mode: request.context_mode().to_owned(), + reused_session_id, + downstream_session_id, + resume_session_id, + quota_limited, + quota_reset_hint, + retry_hint, + }), + } +} + +fn downstream_session_id(detail: &str) -> Option<String> { + let value = serde_json::from_str::<Value>(detail).ok()?; + let session_id = value.get("session_id")?.as_str()?; + SessionHandle::parse(session_id).map(|session| session.display()) +} + +fn quota_reset_hint(detail: &str) -> Option<String> { + let (_, suffix) = detail.split_once("resets ")?; + let hint = suffix.trim(); + (!hint.is_empty()).then(|| hint.to_owned()) +} + +fn consult_retry_hint(quota_limited: bool, resume_session_id: Option<&str>) -> Option<String> { + if quota_limited { + return Some(match resume_session_id { + Some(session_id) => format!( + "wait for the quota window to reset, then retry consult on the same cwd; phone_opus will reuse resume_session {session_id} automatically" + ), + None => { + "wait for the quota window to reset, then retry consult on the same cwd".to_owned() + } + }); } + resume_session_id.map(|session_id| { + format!( + "retry consult on the same cwd; phone_opus will reuse resume_session {session_id} automatically" + ) + }) } pub(crate) fn consult_job_tool_output( diff --git a/crates/phone-opus/tests/mcp_hardening.rs b/crates/phone-opus/tests/mcp_hardening.rs index 6a3130b..e9a664b 100644 --- a/crates/phone-opus/tests/mcp_hardening.rs +++ b/crates/phone-opus/tests/mcp_hardening.rs @@ -788,6 +788,124 @@ fn consult_surfaces_downstream_cli_failures() -> TestResult { .as_str() .is_some_and(|value| value.contains("permission denied by fake claude")) ); + assert_eq!( + tool_content(&consult)["context"]["consult"]["context_mode"].as_str(), + Some("fresh") + ); + assert!(tool_content(&consult)["context"]["consult"]["reused_session_id"].is_null()); + Ok(()) +} + +#[test] +fn quota_failures_surface_resume_context_for_same_cwd() -> TestResult { + let root = temp_root("consult_quota_failure")?; + let state_home = root.join("state-home"); + let sandbox = root.join("sandbox"); + let caller_home = root.join("caller-home"); + must(fs::create_dir_all(&state_home), "create state home")?; + must(fs::create_dir_all(&sandbox), "create sandbox")?; + must(fs::create_dir_all(&caller_home), "create caller home")?; + seed_caller_claude_home(&caller_home)?; + + let fake_claude = root.join("claude"); + let stdout_file = root.join("stdout.json"); + let remembered_session = "84b9d462-5af9-4a4e-8e44-379a8d0c46d7"; + write_fake_claude_script(&fake_claude)?; + write_fake_claude_stdout(&stdout_file, "ok", remembered_session, "uuid-remembered")?; + + let claude_bin = fake_claude.display().to_string(); + let stdout_path = stdout_file.display().to_string(); + let caller_home_path = caller_home.display().to_string(); + let env = [ + ("HOME", caller_home_path.as_str()), + ("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()), + ("PHONE_OPUS_TEST_STDOUT_FILE", stdout_path.as_str()), + ]; + let mut harness = McpHarness::spawn(&state_home, &env)?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + + let first = harness.call_tool( + 3, + "consult", + json!({ + "prompt": "seed remembered session", + "cwd": sandbox.display().to_string() + }), + )?; + assert_tool_ok(&first); + assert_eq!( + tool_content(&first)["session_id"].as_str(), + Some(remembered_session) + ); + + let quota_env = [ + ("HOME", caller_home_path.as_str()), + ("PHONE_OPUS_CLAUDE_BIN", claude_bin.as_str()), + ("PHONE_OPUS_TEST_EXIT_CODE", "17"), + ( + "PHONE_OPUS_TEST_STDERR", + "You've hit your limit · resets 4pm (America/New_York)", + ), + ]; + drop(harness); + let mut harness = McpHarness::spawn(&state_home, "a_env)?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + + let failed = harness.call_tool( + 4, + "consult", + json!({ + "prompt": "quota me", + "cwd": sandbox.display().to_string() + }), + )?; + assert_tool_error(&failed); + assert_eq!( + tool_content(&failed)["fault"]["detail"].as_str(), + Some("You've hit your limit · resets 4pm (America/New_York)") + ); + assert_eq!( + tool_content(&failed)["context"]["consult"]["cwd"].as_str(), + Some(sandbox.display().to_string().as_str()) + ); + assert_eq!( + tool_content(&failed)["context"]["consult"]["context_mode"].as_str(), + Some("reused") + ); + assert_eq!( + tool_content(&failed)["context"]["consult"]["reused_session_id"].as_str(), + Some(remembered_session) + ); + assert_eq!( + tool_content(&failed)["context"]["consult"]["resume_session_id"].as_str(), + Some(remembered_session) + ); + assert_eq!( + tool_content(&failed)["context"]["consult"]["quota_limited"].as_bool(), + Some(true) + ); + assert_eq!( + tool_content(&failed)["context"]["consult"]["quota_reset_hint"].as_str(), + Some("4pm (America/New_York)") + ); + assert!( + tool_content(&failed)["context"]["consult"]["retry_hint"] + .as_str() + .is_some_and(|value| value.contains(remembered_session)) + ); + assert!( + failed["result"]["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry["text"].as_str()) + .any(|text| { + text.contains("resume_session: 84b9d462-5af9-4a4e-8e44-379a8d0c46d7") + && text.contains("quota_reset: 4pm (America/New_York)") + }) + ); Ok(()) } |