diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index acd11967..aa42fa58 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -76,6 +76,7 @@ const PREFLIGHT_CHECK_MODEL_PROVIDER: &str = "model_provider"; const PREFLIGHT_CHECK_SKILLS: &str = "skills"; const PREFLIGHT_CHECK_PLUGINS: &str = "plugins"; const PREFLIGHT_CHECK_MCP: &str = "mcp"; +const PREFLIGHT_PLUGIN_MARKETPLACE_KIND: &str = "local"; const JSONRPC_METHOD_NOT_FOUND: i64 = -32_601; const CHILD_BUCKET_MODEL: &str = "Model"; const CHILD_BUCKET_PROTOCOL: &str = "Protocol"; @@ -1125,6 +1126,21 @@ fn protocol_activity_category(event_type: &str) -> &'static str { if normalized.starts_with("account/") { return "account"; } + if normalized == "deprecationnotice" { + return "deprecation"; + } + if normalized == "warning" || normalized == "configwarning" || normalized == "guardianwarning" { + return "warning"; + } + if normalized.starts_with("model/") { + return "model"; + } + if normalized.contains("tokenusage") { + return "token_usage"; + } + if normalized.contains("reasoning") { + return "reasoning"; + } if normalized == "item/tool/call/failure" { return "protocol_error"; } @@ -1145,6 +1161,8 @@ fn protocol_activity_category(event_type: &str) -> &'static str { } fn protocol_activity_detail(event_type: &str, payload_value: Option<&Value>) -> Option { + let normalized = event_type.to_ascii_lowercase(); + if event_type == "thread/status/changed" { return payload_value.and_then(|value| { string_at_paths(value, &[&["params", "status", "type"], &["status", "type"]]) @@ -1171,6 +1189,37 @@ fn protocol_activity_detail(event_type: &str, payload_value: Option<&Value>) -> if event_type.starts_with("account/") { return protocol_account_detail(payload_value); } + if normalized == "deprecationnotice" + || normalized == "warning" + || normalized == "configwarning" + || normalized == "guardianwarning" + { + return warning_or_deprecation_detail(payload_value); + } + if event_type == "model/rerouted" { + return model_rerouted_detail(payload_value); + } + if event_type == "model/verification" { + return model_verification_detail(payload_value); + } + if normalized.contains("tokenusage") { + return token_usage_detail(payload_value); + } + if normalized.contains("reasoning") { + return payload_value.and_then(|value| { + string_at_paths( + value, + &[ + &["params", "text"], + &["text"], + &["params", "summary"], + &["summary"], + &["params", "part", "text"], + &["part", "text"], + ], + ) + }); + } if event_type == "error" { return payload_value .and_then(|value| { @@ -1185,6 +1234,70 @@ fn protocol_activity_detail(event_type: &str, payload_value: Option<&Value>) -> None } +fn warning_or_deprecation_detail(payload_value: Option<&Value>) -> Option { + payload_value.and_then(|value| { + string_at_paths( + value, + &[ + &["params", "summary"], + &["summary"], + &["params", "message"], + &["message"], + &["params", "details"], + &["details"], + ], + ) + }) +} + +fn model_rerouted_detail(payload_value: Option<&Value>) -> Option { + let value = payload_value?; + let from_model = string_at_paths(value, &[&["params", "fromModel"], &["fromModel"]])?; + let to_model = string_at_paths(value, &[&["params", "toModel"], &["toModel"]])?; + let reason = string_at_paths(value, &[&["params", "reason"], &["reason"]]); + + Some(match reason { + Some(reason) => format!("{from_model}->{to_model}/{reason}"), + None => format!("{from_model}->{to_model}"), + }) +} + +fn model_verification_detail(payload_value: Option<&Value>) -> Option { + let value = payload_value?; + let verifications = value_at_paths(value, &[&["params", "verifications"], &["verifications"]])?; + let verification_count = verifications.as_array()?.len(); + + Some(format!("{verification_count} verification(s)")) +} + +fn token_usage_detail(payload_value: Option<&Value>) -> Option { + let value = payload_value?; + let input_tokens = value_at_paths( + value, + &[ + &["params", "tokenUsage", "total", "inputTokens"], + &["tokenUsage", "total", "inputTokens"], + ], + ) + .and_then(json_number_to_i64); + let output_tokens = value_at_paths( + value, + &[ + &["params", "tokenUsage", "total", "outputTokens"], + &["tokenUsage", "total", "outputTokens"], + ], + ) + .and_then(json_number_to_i64); + + match (input_tokens, output_tokens) { + (Some(input_tokens), Some(output_tokens)) => + Some(format!("input={input_tokens}, output={output_tokens}")), + (Some(input_tokens), None) => Some(format!("input={input_tokens}")), + (None, Some(output_tokens)) => Some(format!("output={output_tokens}")), + (None, None) => None, + } +} + fn protocol_account_detail(payload_value: Option<&Value>) -> Option { let value = payload_value?; let plan = string_at_paths( @@ -1967,7 +2080,7 @@ fn run_app_server_capability_preflight( record_skills_preflight(&mut report, cwd, &skills); let plugins = preflight_request(recorder, &report, "plugin/list", || { - client.list_plugins(&PluginListParams { cwds: Some(vec![cwd.to_owned()]) }) + client.list_plugins(&plugin_list_params_for_preflight(cwd)) })?; record_plugin_preflight(&mut report, &plugins); @@ -1991,6 +2104,13 @@ fn run_app_server_capability_preflight( Ok(report) } +fn plugin_list_params_for_preflight(cwd: &str) -> PluginListParams { + PluginListParams { + cwds: Some(vec![cwd.to_owned()]), + marketplace_kinds: Some(vec![PREFLIGHT_PLUGIN_MARKETPLACE_KIND.to_owned()]), + } +} + fn preflight_method_failure( recorder: &mut RunRecorder<'_>, report: &AppServerCapabilityPreflightReport, diff --git a/apps/decodex/src/agent/app_server/protocol.rs b/apps/decodex/src/agent/app_server/protocol.rs index c4f95f00..82ac4079 100644 --- a/apps/decodex/src/agent/app_server/protocol.rs +++ b/apps/decodex/src/agent/app_server/protocol.rs @@ -531,6 +531,8 @@ pub(super) struct SkillMetadata { pub(super) struct PluginListParams { #[serde(skip_serializing_if = "Option::is_none")] pub(super) cwds: Option>, + #[serde(rename = "marketplaceKinds", skip_serializing_if = "Option::is_none")] + pub(super) marketplace_kinds: Option>, } #[derive(Debug, Deserialize)] diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index ac43ec35..0819ff79 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -644,6 +644,15 @@ fn capability_preflight_report_blocks_missing_runtime_state() { ); } +#[test] +fn plugin_list_preflight_uses_local_marketplaces() { + let params = super::plugin_list_params_for_preflight("/tmp/worktree"); + let serialized = serde_json::to_value(¶ms).expect("plugin params should serialize"); + + assert_eq!(serialized["cwds"], serde_json::json!(["/tmp/worktree"])); + assert_eq!(serialized["marketplaceKinds"], serde_json::json!(["local"])); +} + #[test] fn capability_preflight_method_error_is_typed_operator_blocker() { let mut report = AppServerCapabilityPreflightReport::new(); @@ -1279,6 +1288,63 @@ fn recorder_summarizes_v2_account_rate_limit_notifications() { assert_eq!(event.detail.as_deref(), Some("pro/workspace_member_usage_limit_reached")); } +#[test] +fn recorder_summarizes_codex_app_server_warning_and_model_notifications() { + let temp_dir = TempDir::new().expect("tempdir should create"); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let marker_path = temp_dir.path().to_path_buf(); + let mut recorder = RunRecorder::new(&state_store, "run-1", 1, Some(&marker_path)); + + for (event_type, payload) in [ + ( + "deprecationNotice", + r#"{"method":"deprecationNotice","params":{"summary":"persistExtendedHistory is ignored","details":"Remove the request field."}}"#, + ), + ( + "configWarning", + r#"{"method":"configWarning","params":{"summary":"unknown feature key in config","details":"builtin_mcp"}}"#, + ), + ( + "model/rerouted", + r#"{"method":"model/rerouted","params":{"threadId":"thread-1","turnId":"turn-1","fromModel":"gpt-5.4","toModel":"gpt-5.5","reason":"highRiskCyberActivity"}}"#, + ), + ( + "model/verification", + r#"{"method":"model/verification","params":{"threadId":"thread-1","turnId":"turn-1","verifications":["trustedAccessForCyber"]}}"#, + ), + ( + "thread/tokenUsage/updated", + r#"{"method":"thread/tokenUsage/updated","params":{"threadId":"thread-1","turnId":"turn-1","tokenUsage":{"last":{"inputTokens":10,"cachedInputTokens":0,"outputTokens":5,"reasoningOutputTokens":1,"totalTokens":16},"total":{"inputTokens":100,"cachedInputTokens":12,"outputTokens":30,"reasoningOutputTokens":8,"totalTokens":138},"modelContextWindow":200000}}}"#, + ), + ] { + recorder.record(event_type, payload).expect("protocol event should record"); + } + + let marker = state::read_run_activity_marker_snapshot(temp_dir.path()) + .expect("marker snapshot should load") + .expect("marker snapshot should exist"); + let summary = marker.protocol_activity().expect("protocol activity should be captured"); + let categories = + summary.recent_events.iter().map(|event| event.category.as_str()).collect::>(); + + assert!(categories.contains(&"deprecation")); + assert!(categories.contains(&"warning")); + assert!(categories.contains(&"model")); + assert!(categories.contains(&"token_usage")); + assert!(summary.recent_events.iter().any(|event| { + event.event_type == "deprecationNotice" + && event.detail.as_deref() == Some("persistExtendedHistory is ignored") + })); + assert!(summary.recent_events.iter().any(|event| { + event.event_type == "model/rerouted" + && event.detail.as_deref() == Some("gpt-5.4->gpt-5.5/highRiskCyberActivity") + })); + assert!(summary.recent_events.iter().any(|event| { + event.event_type == "thread/tokenUsage/updated" + && event.detail.as_deref() == Some("input=100, output=30") + })); +} + #[test] fn recorder_does_not_treat_rate_limit_update_method_as_limit_status() { let temp_dir = TempDir::new().expect("tempdir should create"); diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index 35803d99..552af875 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -34,6 +34,9 @@ codex app-server generate-json-schema --experimental --out target/decodex-app-se - `decodex` must treat the generated schema as more authoritative than stale handwritten assumptions. - `--experimental` is required when inspecting `dynamicTools` and related experimental fields in the generated bundle. +- As of the 2026-05 refresh, the local compatibility baseline is + `codex-cli 0.132.0-alpha.1` from `PATH` and the Codex Beta app bundle's + `codex-cli 0.131.0-alpha.9`; both expose `PluginListParams.marketplaceKinds`. ## Implementation guidance @@ -76,7 +79,8 @@ Decodex records a compact local protocol summary from high-value structured notifications instead of scraping transcripts. The summary may include `turn/started`, `turn/completed`, plan updates, diff updates, item start/completion, command output deltas, server request responses, account updates, -and rate-limit updates. This summary is published through the operator status +rate-limit updates, warning/deprecation notices, model reroutes/verifications, and +thread token-usage updates. This summary is published through the operator status snapshot and dashboard only; high-frequency protocol details remain out of Linear unless an existing lifecycle event summarizes them. @@ -99,7 +103,9 @@ The capability preflight is observational. It may inspect the effective app-serv config, model inventory, provider capabilities, skill inventory, plugin inventory, and MCP server state, but it must not install plugins, mutate marketplaces, or send model, personality, sandbox, or approval-policy overrides on behalf of -`WORKFLOW.md`. +`WORKFLOW.md`. `plugin/list` preflight must pass `marketplaceKinds = ["local"]` +so remote catalog, featured-plugin, or marketplace-discovery failures do not gate a +business lane before its thread is created. When dynamic tools are enabled, `decodex` must also: