Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
}
Expand All @@ -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<String> {
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"]])
Expand All @@ -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| {
Expand All @@ -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<String> {
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<String> {
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<String> {
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<String> {
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<String> {
let value = payload_value?;
let plan = string_at_paths(
Expand Down Expand Up @@ -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);
Expand All @@ -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<T>(
recorder: &mut RunRecorder<'_>,
report: &AppServerCapabilityPreflightReport,
Expand Down
2 changes: 2 additions & 0 deletions apps/decodex/src/agent/app_server/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,8 @@ pub(super) struct SkillMetadata {
pub(super) struct PluginListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) cwds: Option<Vec<String>>,
#[serde(rename = "marketplaceKinds", skip_serializing_if = "Option::is_none")]
pub(super) marketplace_kinds: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
Expand Down
66 changes: 66 additions & 0 deletions apps/decodex/src/agent/app_server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params).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();
Expand Down Expand Up @@ -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::<Vec<_>>();

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");
Expand Down
10 changes: 8 additions & 2 deletions docs/spec/app-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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:

Expand Down