@team-agent/installer 0.5.51 → 0.5.53
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 +17 -0
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/named_address.rs +35 -67
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +11 -0
- 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 +526 -73
- 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 +12 -1
- 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/quick_start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
- 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 +20 -4
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
- package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
- package/crates/team-agent/src/lifecycle/restart.rs +25 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -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/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/tests/tools.rs +18 -1
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tests.rs +11 -4
- package/crates/team-agent/src/mcp_server/tools.rs +18 -4
- package/crates/team-agent/src/mcp_server/types.rs +3 -0
- package/crates/team-agent/src/mcp_server/wire.rs +30 -7
- package/crates/team-agent/src/messaging/delivery.rs +126 -147
- package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
- package/crates/team-agent/src/messaging/mod.rs +5 -0
- package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
- 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 +321 -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.rs +39 -6
- 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/state/persist.rs +282 -20
- package/crates/team-agent/src/state/projection.rs +245 -30
- package/crates/team-agent/src/state/repository/intent.rs +13 -0
- package/crates/team-agent/src/state/repository.rs +36 -12
- package/crates/team-agent/src/state/selector.rs +9 -18
- 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/skills/team-agent/SKILL.md +8 -3
|
@@ -666,6 +666,23 @@ pub struct ForkAgentReport {
|
|
|
666
666
|
pub session_id: Option<SessionId>,
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
670
|
+
pub struct CloneAgentReport {
|
|
671
|
+
pub source_agent_id: AgentId,
|
|
672
|
+
pub new_agent_id: AgentId,
|
|
673
|
+
pub env: AgentActionEnvelope,
|
|
674
|
+
pub session_id: Option<SessionId>,
|
|
675
|
+
pub backing_path: Option<PathBuf>,
|
|
676
|
+
pub backing_state: CloneBackingState,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
680
|
+
#[serde(rename_all = "snake_case")]
|
|
681
|
+
pub enum CloneBackingState {
|
|
682
|
+
Verified,
|
|
683
|
+
PendingFirstTurn,
|
|
684
|
+
}
|
|
685
|
+
|
|
669
686
|
/// Read-only remove-agent flag requirements for the target's current state.
|
|
670
687
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
671
688
|
pub struct RemoveAgentFlagRequirements {
|
|
@@ -134,6 +134,41 @@ pub(crate) fn fork_agent(
|
|
|
134
134
|
})
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
pub(crate) fn clone_agent(
|
|
138
|
+
workspace: &Path,
|
|
139
|
+
owner_team: Option<&TeamKey>,
|
|
140
|
+
source_agent_id: &str,
|
|
141
|
+
as_agent_id: &str,
|
|
142
|
+
label: Option<&str>,
|
|
143
|
+
) -> ToolResult {
|
|
144
|
+
emit_newer_daemon_preserved_if_present(workspace)?;
|
|
145
|
+
let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, false)?;
|
|
146
|
+
let report = crate::lifecycle::launch::clone_agent(
|
|
147
|
+
&lifecycle_workspace,
|
|
148
|
+
&AgentId::new(source_agent_id),
|
|
149
|
+
&AgentId::new(as_agent_id),
|
|
150
|
+
label,
|
|
151
|
+
false,
|
|
152
|
+
owner_team.map(TeamKey::as_str),
|
|
153
|
+
)
|
|
154
|
+
.map_err(tool_runtime_error)?;
|
|
155
|
+
Ok(ToolOk {
|
|
156
|
+
fields: object_fields(serde_json::json!({
|
|
157
|
+
"ok": true,
|
|
158
|
+
"status": "cloned",
|
|
159
|
+
"source_agent_id": report.source_agent_id.as_str(),
|
|
160
|
+
"agent_id": report.new_agent_id.as_str(),
|
|
161
|
+
"new_agent_id": report.new_agent_id.as_str(),
|
|
162
|
+
"state_file": report.env.state_file.to_string_lossy().to_string(),
|
|
163
|
+
"coordinator_started": report.env.coordinator_started,
|
|
164
|
+
"session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
165
|
+
"new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
166
|
+
"backing_path": report.backing_path.as_ref().map(|path| path.to_string_lossy().to_string()),
|
|
167
|
+
"backing_state": report.backing_state,
|
|
168
|
+
})),
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
137
172
|
fn reset_refusal_reason(reason: ResetRefusal) -> Value {
|
|
138
173
|
match reason {
|
|
139
174
|
ResetRefusal::DiscardSessionRequired => {
|
|
@@ -440,9 +475,9 @@ fn prepare_selected_team_state(
|
|
|
440
475
|
state,
|
|
441
476
|
)
|
|
442
477
|
.map_err(|e| {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
478
|
+
tool_runtime_error(format!(
|
|
479
|
+
"save MCP lifecycle scoped state {}: {e}",
|
|
480
|
+
workspace.display()
|
|
481
|
+
))
|
|
482
|
+
})
|
|
448
483
|
}
|
|
@@ -311,8 +311,25 @@
|
|
|
311
311
|
// ════════════════════════════════════════════════════════════════════════
|
|
312
312
|
#[test]
|
|
313
313
|
fn update_state_returns_ok_and_state_file_path() {
|
|
314
|
+
let ws = seed_state_ws(
|
|
315
|
+
"update-state",
|
|
316
|
+
&json!({
|
|
317
|
+
"active_team_key": "teamA",
|
|
318
|
+
"team_key": "teamA",
|
|
319
|
+
"session_name": "teamA",
|
|
320
|
+
"status": "alive",
|
|
321
|
+
"teams": {
|
|
322
|
+
"teamA": {
|
|
323
|
+
"team_key": "teamA",
|
|
324
|
+
"session_name": "teamA",
|
|
325
|
+
"status": "alive",
|
|
326
|
+
"agents": {}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
314
331
|
let tools = TeamOrchestratorTools::with_identity(
|
|
315
|
-
&
|
|
332
|
+
&ws,
|
|
316
333
|
Some(AgentId::new("leader")),
|
|
317
334
|
Some(TeamKey::new("teamA")),
|
|
318
335
|
);
|
|
@@ -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. Provide only target and content; Team Agent fills sender, task id, ack policy, and delivery metadata.")
|
|
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
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
@@ -22,12 +22,19 @@ fn keys(v: &Value) -> Vec<String> {
|
|
|
22
22
|
/// `/tmp/ws`, or they flake under parallel cargo (sqlite "database is locked" / NotFound).
|
|
23
23
|
/// Pure-function / dispatch-shape tests that never touch fs/db keep a dummy fixed path.
|
|
24
24
|
fn unique_ws(tag: &str) -> std::path::PathBuf {
|
|
25
|
+
use std::io::ErrorKind;
|
|
25
26
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
26
27
|
static N: AtomicU64 = AtomicU64::new(0);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
loop {
|
|
29
|
+
let n = N.fetch_add(1, Ordering::Relaxed);
|
|
30
|
+
let p =
|
|
31
|
+
std::env::temp_dir().join(format!("ta-rs-mcp-{tag}-{}-{n}", std::process::id()));
|
|
32
|
+
match std::fs::create_dir(&p) {
|
|
33
|
+
Ok(()) => return p,
|
|
34
|
+
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
|
|
35
|
+
Err(error) => panic!("create unique workspace {}: {error}", p.display()),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
include!("tests/normalize.rs");
|
|
@@ -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(
|
|
@@ -572,6 +572,22 @@ impl TeamOrchestratorTools {
|
|
|
572
572
|
)
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
pub fn clone_agent(
|
|
576
|
+
&self,
|
|
577
|
+
source_agent_id: &str,
|
|
578
|
+
as_agent_id: &str,
|
|
579
|
+
label: Option<&str>,
|
|
580
|
+
) -> ToolResult {
|
|
581
|
+
let owner_team = self.canonical_owner_team_key()?;
|
|
582
|
+
super::lifecycle_tools::clone_agent(
|
|
583
|
+
&self.workspace,
|
|
584
|
+
owner_team.as_ref(),
|
|
585
|
+
source_agent_id,
|
|
586
|
+
as_agent_id,
|
|
587
|
+
label,
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
575
591
|
/// `request_human` (`tools.py:342-346`): create a `requires_ack` leader message via
|
|
576
592
|
/// the shared leader-delivery funnel; sender = env / inferred / `"unknown"`.
|
|
577
593
|
/// Returns `{ok:true, message_id, status:"needs_human"}`.
|
|
@@ -797,9 +813,7 @@ impl TeamOrchestratorTools {
|
|
|
797
813
|
.collect();
|
|
798
814
|
let best = ranked.first().cloned();
|
|
799
815
|
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
|
-
)
|
|
816
|
+
format!("the requested peer is not in your owner team; did you mean `{best}`?")
|
|
803
817
|
} else {
|
|
804
818
|
"the requested peer is not part of your team; worker-origin MCP cannot widen team scope.".to_string()
|
|
805
819
|
};
|
|
@@ -63,6 +63,7 @@ pub enum McpTool {
|
|
|
63
63
|
StopAgent,
|
|
64
64
|
ResetAgent,
|
|
65
65
|
AddAgent,
|
|
66
|
+
CloneAgent,
|
|
66
67
|
ForkAgent,
|
|
67
68
|
RequestHuman,
|
|
68
69
|
StuckList,
|
|
@@ -82,6 +83,7 @@ impl McpTool {
|
|
|
82
83
|
McpTool::StopAgent => "stop_agent",
|
|
83
84
|
McpTool::ResetAgent => "reset_agent",
|
|
84
85
|
McpTool::AddAgent => "add_agent",
|
|
86
|
+
McpTool::CloneAgent => "clone_agent",
|
|
85
87
|
McpTool::ForkAgent => "fork_agent",
|
|
86
88
|
McpTool::RequestHuman => "request_human",
|
|
87
89
|
McpTool::StuckList => "stuck_list",
|
|
@@ -101,6 +103,7 @@ impl McpTool {
|
|
|
101
103
|
"stop_agent" => Some(McpTool::StopAgent),
|
|
102
104
|
"reset_agent" => Some(McpTool::ResetAgent),
|
|
103
105
|
"add_agent" => Some(McpTool::AddAgent),
|
|
106
|
+
"clone_agent" => Some(McpTool::CloneAgent),
|
|
104
107
|
"fork_agent" => Some(McpTool::ForkAgent),
|
|
105
108
|
"request_human" => Some(McpTool::RequestHuman),
|
|
106
109
|
"stuck_list" => Some(McpTool::StuckList),
|