@team-agent/installer 0.5.52 → 0.5.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +18 -2
- package/crates/team-agent/src/cli/emit.rs +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +395 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -3
|
@@ -1,193 +1,269 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// unknown → None (server.py:43 maps to UnknownTool)
|
|
22
|
-
assert_eq!(McpTool::parse("nope"), None);
|
|
23
|
-
assert_eq!(McpTool::parse("AssignTask"), None); // case-sensitive snake_case
|
|
1
|
+
#[test]
|
|
2
|
+
fn mcp_tool_wire_names_and_parse_roundtrip() {
|
|
3
|
+
let names = [
|
|
4
|
+
(McpTool::AssignTask, "assign_task"),
|
|
5
|
+
(McpTool::SendMessage, "send_message"),
|
|
6
|
+
(McpTool::ReportResult, "report_result"),
|
|
7
|
+
(McpTool::UpdateState, "update_state"),
|
|
8
|
+
(McpTool::GetTeamStatus, "get_team_status"),
|
|
9
|
+
(McpTool::StopAgent, "stop_agent"),
|
|
10
|
+
(McpTool::ResetAgent, "reset_agent"),
|
|
11
|
+
(McpTool::AddAgent, "add_agent"),
|
|
12
|
+
(McpTool::CloneAgent, "clone_agent"),
|
|
13
|
+
(McpTool::ForkAgent, "fork_agent"),
|
|
14
|
+
(McpTool::RequestHuman, "request_human"),
|
|
15
|
+
(McpTool::StuckList, "stuck_list"),
|
|
16
|
+
(McpTool::StuckCancel, "stuck_cancel"),
|
|
17
|
+
];
|
|
18
|
+
for (tool, name) in names {
|
|
19
|
+
assert_eq!(tool.wire_name(), name);
|
|
20
|
+
assert_eq!(McpTool::parse(name), Some(tool));
|
|
24
21
|
}
|
|
22
|
+
// unknown → None (server.py:43 maps to UnknownTool)
|
|
23
|
+
assert_eq!(McpTool::parse("nope"), None);
|
|
24
|
+
assert_eq!(McpTool::parse("AssignTask"), None); // case-sensitive snake_case
|
|
25
|
+
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
27
|
+
#[test]
|
|
28
|
+
fn rpc_method_classify() {
|
|
29
|
+
assert_eq!(RpcMethod::classify("initialize"), RpcMethod::Initialize);
|
|
30
|
+
assert_eq!(RpcMethod::classify("tools/list"), RpcMethod::ToolsList);
|
|
31
|
+
assert_eq!(RpcMethod::classify("tools/call"), RpcMethod::ToolsCall);
|
|
32
|
+
// notifications/* → Notification (no reply path)
|
|
33
|
+
assert!(matches!(
|
|
34
|
+
RpcMethod::classify("notifications/initialized"),
|
|
35
|
+
RpcMethod::Notification(_)
|
|
36
|
+
));
|
|
37
|
+
// unknown → Unknown
|
|
38
|
+
assert_eq!(
|
|
39
|
+
RpcMethod::classify("foo/bar"),
|
|
40
|
+
RpcMethod::Unknown("foo/bar".to_string())
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
45
|
+
// tools_contract — TOOLS wire list, exact names+order
|
|
46
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
47
|
+
#[test]
|
|
48
|
+
fn tools_contract_has_thirteen_tools_in_order() {
|
|
49
|
+
let tools = tools_contract();
|
|
50
|
+
assert_eq!(tools.len(), 13);
|
|
51
|
+
let got: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
|
52
|
+
assert_eq!(
|
|
53
|
+
got,
|
|
54
|
+
vec![
|
|
55
|
+
"assign_task",
|
|
56
|
+
"send_message",
|
|
57
|
+
"report_result",
|
|
58
|
+
"update_state",
|
|
59
|
+
"get_team_status",
|
|
60
|
+
"stop_agent",
|
|
61
|
+
"reset_agent",
|
|
62
|
+
"add_agent",
|
|
63
|
+
"clone_agent",
|
|
64
|
+
"fork_agent",
|
|
65
|
+
"request_human",
|
|
66
|
+
"stuck_list",
|
|
67
|
+
"stuck_cancel",
|
|
68
|
+
]
|
|
69
|
+
);
|
|
70
|
+
// each carries description + inputSchema
|
|
71
|
+
for t in &tools {
|
|
72
|
+
assert!(t.get("description").and_then(Value::as_str).is_some());
|
|
73
|
+
assert!(t.get("inputSchema").is_some());
|
|
74
|
+
}
|
|
75
|
+
// spot-check byte-stable description + schema for send_message
|
|
76
|
+
let send = tools
|
|
77
|
+
.iter()
|
|
78
|
+
.find(|t| t["name"] == json!("send_message"))
|
|
79
|
+
.unwrap();
|
|
80
|
+
assert_eq!(
|
|
81
|
+
send["description"],
|
|
82
|
+
json!("Send a message to a teammate, the leader, or '*' for all other team members. Team Agent fills identity and delivery metadata; optional presentation routing is durable and never drops the message.")
|
|
83
|
+
);
|
|
84
|
+
assert_eq!(send["inputSchema"]["additionalProperties"], json!(false));
|
|
85
|
+
assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
|
|
86
|
+
for internal in ["sender", "task_id", "requires_ack"] {
|
|
87
|
+
assert!(
|
|
88
|
+
send["inputSchema"]["properties"].get(internal).is_none(),
|
|
89
|
+
"{internal} is framework-owned, not caller-supplied"
|
|
40
90
|
);
|
|
41
91
|
}
|
|
92
|
+
let clone = tools
|
|
93
|
+
.iter()
|
|
94
|
+
.find(|tool| tool["name"] == json!("clone_agent"))
|
|
95
|
+
.unwrap();
|
|
96
|
+
assert_eq!(
|
|
97
|
+
clone["description"],
|
|
98
|
+
json!("Clone a worker role into a fresh provider session.")
|
|
99
|
+
);
|
|
100
|
+
assert_eq!(clone["inputSchema"]["additionalProperties"], json!(false));
|
|
101
|
+
assert_eq!(
|
|
102
|
+
clone["inputSchema"]["required"],
|
|
103
|
+
json!(["source_agent_id", "as_agent_id"])
|
|
104
|
+
);
|
|
105
|
+
assert_eq!(
|
|
106
|
+
clone["inputSchema"]["properties"]
|
|
107
|
+
.as_object()
|
|
108
|
+
.unwrap()
|
|
109
|
+
.keys()
|
|
110
|
+
.cloned()
|
|
111
|
+
.collect::<BTreeSet<_>>(),
|
|
112
|
+
BTreeSet::from([
|
|
113
|
+
"as_agent_id".to_string(),
|
|
114
|
+
"label".to_string(),
|
|
115
|
+
"source_agent_id".to_string(),
|
|
116
|
+
])
|
|
117
|
+
);
|
|
118
|
+
}
|
|
42
119
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
let tools = tools_contract();
|
|
49
|
-
assert_eq!(tools.len(), 12);
|
|
50
|
-
let got: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
|
51
|
-
assert_eq!(got, vec![
|
|
52
|
-
"assign_task", "send_message", "report_result", "update_state",
|
|
53
|
-
"get_team_status", "stop_agent", "reset_agent", "add_agent",
|
|
54
|
-
"fork_agent", "request_human", "stuck_list", "stuck_cancel",
|
|
55
|
-
]);
|
|
56
|
-
// each carries description + inputSchema
|
|
57
|
-
for t in &tools {
|
|
58
|
-
assert!(t.get("description").and_then(Value::as_str).is_some());
|
|
59
|
-
assert!(t.get("inputSchema").is_some());
|
|
60
|
-
}
|
|
61
|
-
// spot-check byte-stable description + schema for send_message
|
|
62
|
-
let send = tools.iter().find(|t| t["name"] == json!("send_message")).unwrap();
|
|
120
|
+
#[test]
|
|
121
|
+
fn tools_contract_input_schemas_are_openai_strict_top_level_objects() {
|
|
122
|
+
let forbidden = ["oneOf", "anyOf", "allOf", "enum", "not"];
|
|
123
|
+
for tool in tools_contract() {
|
|
124
|
+
let schema = tool["inputSchema"].as_object().unwrap();
|
|
63
125
|
assert_eq!(
|
|
64
|
-
|
|
65
|
-
json!("
|
|
126
|
+
schema.get("type"),
|
|
127
|
+
Some(&json!("object")),
|
|
128
|
+
"schema must be a top-level object: {tool}"
|
|
66
129
|
);
|
|
67
|
-
|
|
68
|
-
assert_eq!(send["inputSchema"]["required"], json!(["to", "content"]));
|
|
69
|
-
for internal in ["sender", "task_id", "requires_ack"] {
|
|
130
|
+
for key in forbidden {
|
|
70
131
|
assert!(
|
|
71
|
-
|
|
72
|
-
"{
|
|
132
|
+
!schema.contains_key(key),
|
|
133
|
+
"OpenAI rejects top-level `{key}` in MCP tool schema: {tool}"
|
|
73
134
|
);
|
|
74
135
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.unwrap_or_else(|| panic!("schema properties must be an object: {tool}"));
|
|
93
|
-
for required in schema.get("required").and_then(Value::as_array).into_iter().flatten() {
|
|
94
|
-
let Some(name) = required.as_str() else {
|
|
95
|
-
panic!("required entries must be strings: {tool}");
|
|
96
|
-
};
|
|
97
|
-
assert!(
|
|
98
|
-
properties.contains_key(name),
|
|
99
|
-
"required property `{name}` must be declared in properties: {tool}"
|
|
100
|
-
);
|
|
101
|
-
}
|
|
136
|
+
let properties = schema
|
|
137
|
+
.get("properties")
|
|
138
|
+
.and_then(Value::as_object)
|
|
139
|
+
.unwrap_or_else(|| panic!("schema properties must be an object: {tool}"));
|
|
140
|
+
for required in schema
|
|
141
|
+
.get("required")
|
|
142
|
+
.and_then(Value::as_array)
|
|
143
|
+
.into_iter()
|
|
144
|
+
.flatten()
|
|
145
|
+
{
|
|
146
|
+
let Some(name) = required.as_str() else {
|
|
147
|
+
panic!("required entries must be strings: {tool}");
|
|
148
|
+
};
|
|
149
|
+
assert!(
|
|
150
|
+
properties.contains_key(name),
|
|
151
|
+
"required property `{name}` must be declared in properties: {tool}"
|
|
152
|
+
);
|
|
102
153
|
}
|
|
103
154
|
}
|
|
155
|
+
}
|
|
104
156
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
157
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
158
|
+
// handle_mcp — JSON-RPC routing (server.py:46-91)
|
|
159
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
160
|
+
#[test]
|
|
161
|
+
fn handle_mcp_initialize_echoes_protocol_and_serverinfo() {
|
|
162
|
+
let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
|
|
163
|
+
let resp = handle_mcp(
|
|
164
|
+
&tools,
|
|
165
|
+
&json!({
|
|
112
166
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
113
167
|
"params": {"protocolVersion": "X"}
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
.unwrap()
|
|
171
|
+
.expect("initialize yields a frame");
|
|
172
|
+
assert_eq!(resp.jsonrpc, "2.0");
|
|
173
|
+
assert_eq!(resp.id, RpcId::Int(1));
|
|
174
|
+
let result = resp.result.unwrap();
|
|
175
|
+
assert_eq!(result["protocolVersion"], json!("X"));
|
|
176
|
+
assert_eq!(result["serverInfo"]["name"], json!("team_orchestrator"));
|
|
177
|
+
assert_eq!(result["serverInfo"]["version"], json!("0.1.4"));
|
|
178
|
+
assert_eq!(result["capabilities"], json!({"tools": {}}));
|
|
179
|
+
}
|
|
123
180
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
181
|
+
#[test]
|
|
182
|
+
fn handle_mcp_initialize_defaults_protocol_version() {
|
|
183
|
+
let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
|
|
184
|
+
let resp = handle_mcp(
|
|
185
|
+
&tools,
|
|
186
|
+
&json!({
|
|
128
187
|
"jsonrpc": "2.0", "id": "abc", "method": "initialize"
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
.unwrap()
|
|
191
|
+
.unwrap();
|
|
192
|
+
assert_eq!(resp.id, RpcId::Str("abc".to_string()));
|
|
193
|
+
assert_eq!(resp.result.unwrap()["protocolVersion"], json!("2024-11-05"));
|
|
194
|
+
}
|
|
133
195
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
196
|
+
#[test]
|
|
197
|
+
fn handle_mcp_notifications_return_none_no_frame() {
|
|
198
|
+
// 铁律: notifications/* MUST NOT emit a frame (would corrupt stdout stream).
|
|
199
|
+
let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
|
|
200
|
+
let resp = handle_mcp(
|
|
201
|
+
&tools,
|
|
202
|
+
&json!({
|
|
139
203
|
"jsonrpc": "2.0", "method": "notifications/initialized"
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
.unwrap();
|
|
207
|
+
assert!(resp.is_none(), "notifications/* → None (loop continues)");
|
|
208
|
+
}
|
|
143
209
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
210
|
+
#[test]
|
|
211
|
+
fn handle_mcp_unknown_method_is_minus_32601() {
|
|
212
|
+
let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
|
|
213
|
+
let resp = handle_mcp(
|
|
214
|
+
&tools,
|
|
215
|
+
&json!({
|
|
148
216
|
"jsonrpc": "2.0", "id": 7, "method": "foo/bar"
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
.unwrap()
|
|
220
|
+
.unwrap();
|
|
221
|
+
assert!(resp.result.is_none());
|
|
222
|
+
let err = resp.error.unwrap();
|
|
223
|
+
assert_eq!(err.code, -32601);
|
|
224
|
+
assert_eq!(err.message, "unknown method 'foo/bar'"); // exact Python repr w/ quotes
|
|
225
|
+
}
|
|
155
226
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
227
|
+
#[test]
|
|
228
|
+
fn handle_mcp_unknown_tool_call_is_error_with_envelope_text() {
|
|
229
|
+
// tools/call with unknown tool → isError:true, content[0].text == json.dumps(envelope)
|
|
230
|
+
let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
|
|
231
|
+
let resp = handle_mcp(
|
|
232
|
+
&tools,
|
|
233
|
+
&json!({
|
|
161
234
|
"jsonrpc": "2.0", "id": 9, "method": "tools/call",
|
|
162
235
|
"params": {"name": "nope", "arguments": {}}
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
236
|
+
}),
|
|
237
|
+
)
|
|
238
|
+
.unwrap()
|
|
239
|
+
.unwrap();
|
|
240
|
+
let result = resp.result.unwrap();
|
|
241
|
+
assert_eq!(result["isError"], json!(true));
|
|
242
|
+
let text = result["content"][0]["text"].as_str().unwrap();
|
|
243
|
+
// the text is a JSON-encoded error envelope with redundant keys
|
|
244
|
+
let env: Value = serde_json::from_str(text).unwrap();
|
|
245
|
+
assert_eq!(env["ok"], json!(false));
|
|
246
|
+
assert_eq!(env["reason"], json!("unknown_tool"));
|
|
247
|
+
assert_eq!(env["error_code"], json!("unknown_tool"));
|
|
248
|
+
assert_eq!(env["exc_type"], json!("UnknownTool"));
|
|
249
|
+
assert_eq!(env["message"], json!("unknown tool 'nope'"));
|
|
250
|
+
assert_eq!(env["error"], json!("unknown tool 'nope'"));
|
|
251
|
+
assert_eq!(result["content"][0]["type"], json!("text"));
|
|
252
|
+
}
|
|
177
253
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
254
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
255
|
+
// dispatch — unknown tool → Err(UnknownTool) (server.py:43)
|
|
256
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
257
|
+
#[test]
|
|
258
|
+
fn dispatch_unknown_tool_returns_unknown_tool_error() {
|
|
259
|
+
let tools = TeamOrchestratorTools::with_identity(Path::new("/tmp/ws"), None, None);
|
|
260
|
+
let r = dispatch(&tools, &json!({"tool": "nope"}));
|
|
261
|
+
let err = r.expect_err("unknown tool ⇒ Err");
|
|
262
|
+
assert_eq!(err.reason, ToolErrorReason::UnknownTool);
|
|
263
|
+
assert_eq!(err.exc_type, "UnknownTool");
|
|
264
|
+
assert_eq!(err.message, "unknown tool 'nope'");
|
|
265
|
+
}
|
|
190
266
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
267
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
268
|
+
// requires_ack_for_target — leader-only → false (tools.py:16)
|
|
269
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -139,7 +139,7 @@ impl TeamOrchestratorTools {
|
|
|
139
139
|
reconcile_assigned_task(latest, latest_team_key.as_deref(), &task_value);
|
|
140
140
|
},
|
|
141
141
|
)
|
|
142
|
-
|
|
142
|
+
.map_err(tool_runtime_error)?;
|
|
143
143
|
|
|
144
144
|
let content = assignment_message(task, message);
|
|
145
145
|
let out = self.send_message(
|
|
@@ -178,6 +178,34 @@ impl TeamOrchestratorTools {
|
|
|
178
178
|
requires_ack: Option<bool>,
|
|
179
179
|
scope_override: Option<Scope>,
|
|
180
180
|
) -> Result<SendOutcome, ToolError> {
|
|
181
|
+
self.send_message_with_presentation(
|
|
182
|
+
to,
|
|
183
|
+
content,
|
|
184
|
+
task_id,
|
|
185
|
+
requires_ack,
|
|
186
|
+
scope_override,
|
|
187
|
+
None,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pub fn send_message_with_presentation(
|
|
192
|
+
&self,
|
|
193
|
+
to: &MessageTarget,
|
|
194
|
+
content: &str,
|
|
195
|
+
task_id: Option<&str>,
|
|
196
|
+
requires_ack: Option<bool>,
|
|
197
|
+
scope_override: Option<Scope>,
|
|
198
|
+
presentation: Option<&Value>,
|
|
199
|
+
) -> Result<SendOutcome, ToolError> {
|
|
200
|
+
let (presentation, presentation_error) =
|
|
201
|
+
crate::messaging::presentation::normalize_presentation(presentation);
|
|
202
|
+
if let Some(error) = presentation_error {
|
|
203
|
+
return Err(ToolError::new(
|
|
204
|
+
ToolErrorReason::InvalidToolArguments,
|
|
205
|
+
format!("invalid presentation: {error}"),
|
|
206
|
+
"PresentationError",
|
|
207
|
+
));
|
|
208
|
+
}
|
|
181
209
|
let canonical_owner_team = self.canonical_owner_team_key()?;
|
|
182
210
|
if matches!(scope_override, Some(Scope::Workspace)) {
|
|
183
211
|
return Err(self.rpc_scope_refused(
|
|
@@ -227,6 +255,7 @@ impl TeamOrchestratorTools {
|
|
|
227
255
|
sender,
|
|
228
256
|
requires_ack: ack,
|
|
229
257
|
team: canonical_owner_team,
|
|
258
|
+
presentation,
|
|
230
259
|
..SendOptions::default()
|
|
231
260
|
};
|
|
232
261
|
if is_worker_recipient(to) {
|
|
@@ -322,6 +351,36 @@ impl TeamOrchestratorTools {
|
|
|
322
351
|
next_actions: Option<&[Value]>,
|
|
323
352
|
task_id: Option<&str>,
|
|
324
353
|
agent_id: Option<&str>,
|
|
354
|
+
) -> ToolResult {
|
|
355
|
+
self.report_result_with_presentation(
|
|
356
|
+
envelope,
|
|
357
|
+
summary,
|
|
358
|
+
status,
|
|
359
|
+
changes,
|
|
360
|
+
tests,
|
|
361
|
+
risks,
|
|
362
|
+
artifacts,
|
|
363
|
+
next_actions,
|
|
364
|
+
task_id,
|
|
365
|
+
agent_id,
|
|
366
|
+
None,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#[allow(clippy::too_many_arguments)]
|
|
371
|
+
pub fn report_result_with_presentation(
|
|
372
|
+
&self,
|
|
373
|
+
envelope: Option<&Value>,
|
|
374
|
+
summary: Option<&str>,
|
|
375
|
+
status: ResultStatus,
|
|
376
|
+
changes: Option<&[Value]>,
|
|
377
|
+
tests: Option<&[Value]>,
|
|
378
|
+
risks: Option<&[Value]>,
|
|
379
|
+
artifacts: Option<&[Value]>,
|
|
380
|
+
next_actions: Option<&[Value]>,
|
|
381
|
+
task_id: Option<&str>,
|
|
382
|
+
agent_id: Option<&str>,
|
|
383
|
+
presentation: Option<&Value>,
|
|
325
384
|
) -> ToolResult {
|
|
326
385
|
if let Some(envelope) = envelope {
|
|
327
386
|
self.validate_rpc_scope_args("report_result", envelope)?;
|
|
@@ -331,6 +390,11 @@ impl TeamOrchestratorTools {
|
|
|
331
390
|
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
|
|
332
391
|
ensure_object(&mut base);
|
|
333
392
|
if let Some(obj) = base.as_object_mut() {
|
|
393
|
+
if !obj.contains_key("presentation") {
|
|
394
|
+
if let Some(presentation) = presentation {
|
|
395
|
+
obj.insert("presentation".to_string(), presentation.clone());
|
|
396
|
+
}
|
|
397
|
+
}
|
|
334
398
|
if !obj.contains_key("summary") {
|
|
335
399
|
obj.insert(
|
|
336
400
|
"summary".to_string(),
|
|
@@ -459,6 +523,13 @@ impl TeamOrchestratorTools {
|
|
|
459
523
|
self.note_unknown_result_status(&raw);
|
|
460
524
|
}
|
|
461
525
|
let normalized = normalize_report_envelope(&base);
|
|
526
|
+
if let Some(error) = normalized.presentation_error.as_deref() {
|
|
527
|
+
return Err(ToolError::new(
|
|
528
|
+
ToolErrorReason::InvalidToolArguments,
|
|
529
|
+
format!("invalid presentation: {error}"),
|
|
530
|
+
"PresentationError",
|
|
531
|
+
));
|
|
532
|
+
}
|
|
462
533
|
let warnings = report_result_integrity_warnings(&base, &normalized);
|
|
463
534
|
let mut env_value = normalized_envelope_value(&normalized);
|
|
464
535
|
copy_report_attribution_fields(&base, &mut env_value);
|
|
@@ -572,6 +643,22 @@ impl TeamOrchestratorTools {
|
|
|
572
643
|
)
|
|
573
644
|
}
|
|
574
645
|
|
|
646
|
+
pub fn clone_agent(
|
|
647
|
+
&self,
|
|
648
|
+
source_agent_id: &str,
|
|
649
|
+
as_agent_id: &str,
|
|
650
|
+
label: Option<&str>,
|
|
651
|
+
) -> ToolResult {
|
|
652
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
653
|
+
super::lifecycle_tools::clone_agent(
|
|
654
|
+
&self.workspace,
|
|
655
|
+
owner_team.as_ref(),
|
|
656
|
+
source_agent_id,
|
|
657
|
+
as_agent_id,
|
|
658
|
+
label,
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
575
662
|
/// `request_human` (`tools.py:342-346`): create a `requires_ack` leader message via
|
|
576
663
|
/// the shared leader-delivery funnel; sender = env / inferred / `"unknown"`.
|
|
577
664
|
/// Returns `{ok:true, message_id, status:"needs_human"}`.
|
|
@@ -797,9 +884,7 @@ impl TeamOrchestratorTools {
|
|
|
797
884
|
.collect();
|
|
798
885
|
let best = ranked.first().cloned();
|
|
799
886
|
let hint = if let Some(best) = best.as_deref() {
|
|
800
|
-
format!(
|
|
801
|
-
"the requested peer is not in your owner team; did you mean `{best}`?"
|
|
802
|
-
)
|
|
887
|
+
format!("the requested peer is not in your owner team; did you mean `{best}`?")
|
|
803
888
|
} else {
|
|
804
889
|
"the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
|
|
805
890
|
};
|
|
@@ -5,6 +5,7 @@ use serde_json::Value;
|
|
|
5
5
|
use thiserror::Error;
|
|
6
6
|
|
|
7
7
|
// ── REUSE: step 2 model (ids + normalized-envelope value enums) ─────────────
|
|
8
|
+
use crate::messaging::presentation::PresentationDecision;
|
|
8
9
|
use crate::model::enums::{ChangeKind, ResultStatus, RiskSeverity, TestStatus};
|
|
9
10
|
use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
10
11
|
|
|
@@ -63,6 +64,7 @@ pub enum McpTool {
|
|
|
63
64
|
StopAgent,
|
|
64
65
|
ResetAgent,
|
|
65
66
|
AddAgent,
|
|
67
|
+
CloneAgent,
|
|
66
68
|
ForkAgent,
|
|
67
69
|
RequestHuman,
|
|
68
70
|
StuckList,
|
|
@@ -82,6 +84,7 @@ impl McpTool {
|
|
|
82
84
|
McpTool::StopAgent => "stop_agent",
|
|
83
85
|
McpTool::ResetAgent => "reset_agent",
|
|
84
86
|
McpTool::AddAgent => "add_agent",
|
|
87
|
+
McpTool::CloneAgent => "clone_agent",
|
|
85
88
|
McpTool::ForkAgent => "fork_agent",
|
|
86
89
|
McpTool::RequestHuman => "request_human",
|
|
87
90
|
McpTool::StuckList => "stuck_list",
|
|
@@ -101,6 +104,7 @@ impl McpTool {
|
|
|
101
104
|
"stop_agent" => Some(McpTool::StopAgent),
|
|
102
105
|
"reset_agent" => Some(McpTool::ResetAgent),
|
|
103
106
|
"add_agent" => Some(McpTool::AddAgent),
|
|
107
|
+
"clone_agent" => Some(McpTool::CloneAgent),
|
|
104
108
|
"fork_agent" => Some(McpTool::ForkAgent),
|
|
105
109
|
"request_human" => Some(McpTool::RequestHuman),
|
|
106
110
|
"stuck_list" => Some(McpTool::StuckList),
|
|
@@ -364,6 +368,9 @@ pub struct NormalizedReportEnvelope {
|
|
|
364
368
|
pub risks: Vec<NormalizedRisk>,
|
|
365
369
|
pub artifacts: Vec<NormalizedArtifact>,
|
|
366
370
|
pub next_actions: Vec<NormalizedNextAction>,
|
|
371
|
+
pub presentation: PresentationDecision,
|
|
372
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
373
|
+
pub presentation_error: Option<String>,
|
|
367
374
|
}
|
|
368
375
|
|
|
369
376
|
/// `changes[]` (`normalize.py:126-142`): path + regularized [`ChangeKind`] +
|