From ce170d51428edffbbaf21b71cc0a54a1113ca460 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 10 Jun 2026 17:48:18 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"accept thread-bound dynamic tool calls","authority":"XY-890"} --- apps/decodex/src/agent/app_server.rs | 17 +++---- apps/decodex/src/agent/app_server/tests.rs | 54 ++++++++++++++++++++++ docs/spec/app-server.md | 2 +- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index fb7ac9b93..3a04ae0a1 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -5384,14 +5384,15 @@ fn validated_dynamic_tool_call_payload( if let Some(target_turn_id) = target_turn_id && payload.turn_id != target_turn_id { - return Err(Box::new(DynamicToolCallDispatch::protocol_failure( - Some(payload.tool), - payload.namespace, - format!( - "Dynamic tool call targeted turn `{}`, but the active turn is `{target_turn_id}`.", - payload.turn_id - ), - ))); + tracing::warn!( + target_thread_id, + target_turn_id, + payload_thread_id = payload.thread_id.as_str(), + payload_turn_id = payload.turn_id.as_str(), + tool = payload.tool.as_str(), + namespace = payload.namespace.as_deref().unwrap_or(""), + "Dynamic tool call turn id differed from the active turn; accepting thread-bound request." + ); } Ok(payload) diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index 8a2accd31..68def3248 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -1917,6 +1917,60 @@ fn dynamic_tool_call_enforces_declared_namespace() { } } +#[test] +fn dynamic_tool_call_accepts_thread_bound_request_when_payload_turn_id_differs() { + let handler = NamespacedDynamicToolHandler { seen_namespace: RefCell::new(None) }; + let request = JsonRpcRequest { + id: serde_json::json!(1), + method: String::from("item/tool/call"), + params: serde_json::json!({ + "arguments": {}, + "callId": "call-1", + "namespace": "tracker", + "threadId": "thread-1", + "tool": "tracker_tool", + "turnId": "tool-call-turn" + }), + }; + let dispatch = + super::handle_dynamic_tool_call(Some(&handler), &request, "thread-1", Some("active-turn")); + + assert!(dispatch.response.success); + assert!(dispatch.terminal_failure.is_none()); + assert_eq!(*handler.seen_namespace.borrow(), Some(String::from("tracker"))); +} + +#[test] +fn dynamic_tool_call_rejects_wrong_thread_even_when_payload_turn_id_differs() { + let handler = NamespacedDynamicToolHandler { seen_namespace: RefCell::new(None) }; + let request = JsonRpcRequest { + id: serde_json::json!(1), + method: String::from("item/tool/call"), + params: serde_json::json!({ + "arguments": {}, + "callId": "call-1", + "namespace": "tracker", + "threadId": "thread-2", + "tool": "tracker_tool", + "turnId": "tool-call-turn" + }), + }; + let dispatch = + super::handle_dynamic_tool_call(Some(&handler), &request, "thread-1", Some("active-turn")); + + assert!(!dispatch.response.success); + assert_eq!(*handler.seen_namespace.borrow(), None); + assert_eq!( + dispatch.terminal_failure.as_ref().map(super::AppServerDynamicToolFailure::error_class), + Some("app_server_dynamic_tool_protocol_failure") + ); + assert!(matches!( + dispatch.response.content_items.as_slice(), + [DynamicToolContentItem::InputText { text }] + if text.contains("targeted thread `thread-2`") + )); +} + #[test] fn dynamic_tool_call_rejects_invalid_response_shape() { let handler = EmptyToolResponseHandler; diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index 108d3d5fe..dc6297170 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -215,7 +215,7 @@ When dynamic tools are enabled, `decodex` must also: 2. Answer `item/tool/call` requests with `DynamicToolCallResponse`. 3. Serialize dynamic tool output items with schema-approved `type` values such as `inputText`. 4. Keep every `dynamicTools[].name` and populated `dynamicTools[].namespace` within the app-server identifier pattern `^[a-zA-Z0-9_-]+$`. -5. Validate incoming `item/tool/call` thread, turn, tool-name, namespace, and response shape before treating the request as handled. +5. Validate incoming `item/tool/call` thread, tool-name, namespace, and response shape before treating the request as handled. Dynamic tool request `turnId` is diagnostic context only: the app-server may emit a request-scoped turn id that differs from the active `turn/start` id, so Decodex records mismatches but keeps the authorization boundary on the active thread and declared tool surface. The client-side dynamic bridge may expose narrow Decodex-owned tools that are local to one run attempt, such as the deferred `decodex.decodex_run_context` tool. These tools must stay small and side-effect-bounded so they can move to a process-local MCP server if the surface expands. Broader stateful or cross-service tool families remain MCP candidates rather than reasons to grow the client bridge indefinitely.